There are three steps to getting HTML5 pushState working in an Angular/Rails SPA.
The first step is to enable pushState within Angular. This is super simple.
If you just do the first step it will appear to work, but you’ll have a subtle issue I call “the reload problem” that will need to be addressed for both the development environment and the production environment. I’ll get into the details of that shortly. For now just know that the steps are:
- Enable pushState in Angular
- Fix the reload problem for the development environment
- Fix the reload problem for the production environment
January 2014 update: it seems that “the reload problem” is no longer an issue. I believe you can safely ignore the steps to fix this problem.
And by the way, this post makes a few assumptions about the way your project is set up. You may want to read or at least skim How to Write Up Ruby on Rails and AngularJS as a Single-Page Application before you dive deep into this post. My advice doesn’t require your app to be set up exactly this way for my basic instructions to make sense, but reading that post might help you understand where I’m coming from.
Enabling pushState Within Angular
Enabling push state is a matter of adding one line to your Angular config: $locationProvider.html5Mode(true);
In case you’d like to know exactly where you should be adding this line, here’s my complete app.js
file (which includes the relevant line and also a bunch of irrelevant crap). Notice that I had to specifically add $locationProvider
as a parameter to the anonymous function I’m passing to the first app.config
.
// app/scripts/app.js
'use strict';
/**
* @ngdoc overview
* @name lunchHubApp
* @description
* # lunchHubApp
*
* Main module of the application.
*/
var app = angular.module('lunchHubApp', [
'ngAnimate',
'ngCookies',
'ngResource',
'ngRoute',
'ngSanitize',
'ngTouch',
'rails',
'ng-token-auth'
]);
app.config(function($routeProvider, $locationProvider) {
$locationProvider.html5Mode(true);
$routeProvider
.when('/', {
templateUrl: 'views/main.html',
controller: 'MainCtrl'
})
.when('/groups', {
templateUrl: 'views/groups.html',
controller: 'GroupsCtrl'
})
.when('/sign_in', {
templateUrl: 'views/user_sessions/sign_in.html',
controller: 'UserSessionsCtrl'
})
.otherwise({
redirectTo: '/'
});
});
app.config(function($authProvider) {
$authProvider.configure({
apiUrl: '/api'
});
});
app.factory('Group', ['railsResourceFactory', function (railsResourceFactory) {
return railsResourceFactory({ url: '/api/groups', name: 'group' });
}]);
You will of course also have to remove the #/
portion from any of your links. So if you have a link to /#/sign_in
, you’ll have to change it to /sign_in
. Hopefully you’re making this change early enough in your application’s life that that change is not a very big deal.
You’ll also need to change your asset references from relative to absolute. So <script src="scripts/app.js"></script>
will need to become <script src="/scripts/app.js"></script>
.
The Reload Problem (And How To Fix It)
Like I said, if you enable pushState in Angular and that’s all you do, it will appear to work as you click around to your different links. But if you reload any page other than the root URL, it won’t work. Let me explain that in detail.
If you navigate to http://localhost:9000/
(which is known as the root URL) and then click on a link that goes to /sign_in
, that state change (not location change because this is a single-page application) will be going through Angular’s routing. We’re good so far.
But if you’re at http://localhost:9000/sign_in
and you click reload, it doesn’t work. The reason is that your server is now trying to find some page that lives at http://localhost:9000/sign_in
and, correctly, it’s determining that such a page does not exist. And again, correctly, it’s showing you an error.
What we need to do is kind of trick the server a little bit. When we get a request for http://localhost:9000/sign_in
, we need to actually serve up http://localhost:9000/
and then tell Angular that we want to be at http://localhost:9000/sign_in
.
And by the way, http://localhost:9000/
and http://localhost:9000/index.html
are functionally equivalent, and in our redirections we’ll be talking about /index.html
. I mention this so you don’t get confused by the /index.html
references.
Development Page Reloads
The solution in both production and development is to add a little redirect that takes you from whatever URI is requested and puts you at index.html
. In development we add the redirect to Grunt because Grunt is serving our client-side part of our application. In production we add it to Rack because Rack is serving the whole thing.
Before we add the rewrite to Grunt we need to install the connect-modrewrite
npm module.
$ npm install --save connect-modrewrite
Here’s the rewrite rule we add. There are a couple cases where a redirect would not make sense: a) when we’re dealing with an API call (which is necessarily a Rails thing and doesn’t involve HTML5 pushState) and b) when we’re serving an actual file. So our rewrite rule says “redirect anything that’s not an API call or a file.”
We add these couple lines to Gruntfile.js
. If it’s not entirely clear where these lines go, don’t worry. I included my full Gruntfile.js
below so you can see exactly where they go.
// Gruntfile.js
// This goes near the beginning of Gruntfile.js.
var modRewrite = require('connect-modrewrite');
// This goes inside of your "connect" options.
var middlewares = [
// Redirect anything that's not a file or an API call to /index.html.
// This allows HTML5 pushState to work on page reloads.
modRewrite(['!/api|/assets|\\..+$ /index.html']),
// any other middlewares
];
Here’s my entire Gruntfile.js
:
// Gruntfile.js
// Generated on 2014-07-18 using generator-angular 0.9.5
'use strict';
// # Globbing
// for performance reasons we're only matching one level down:
// 'test/spec/{,*/}*.js'
// use this if you want to recursively match all subfolders:
// 'test/spec/**/*.js'
module.exports = function (grunt) {
// Load grunt tasks automatically
require('load-grunt-tasks')(grunt);
// Time how long tasks take. Can help when optimizing build times
require('time-grunt')(grunt);
// Configurable paths for the application
var appConfig = {
app: require('./bower.json').appPath || 'app',
dist: '../public'
};
var modRewrite = require('connect-modrewrite');
// Define the configuration for all the tasks
grunt.initConfig({
protractor: {
options: {
keepAlive: true, // If false, the grunt process stops when the test fails.
noColor: false, // If true, protractor will not use colors in its output.
args: {
// Arguments passed to the command
}
},
run: {
options: {
configFile: 'protractor_conf.js', // Target-specific config file
args: {} // Target-specific arguments
}
},
},
uglify: {
options: {
beautify: true,
mangle: true
}
},
// Project settings
yeoman: appConfig,
// Watches files for changes and runs tasks based on the changed files
watch: {
bower: {
files: ['bower.json'],
tasks: ['wiredep']
},
js: {
files: ['<%= yeoman.app %>/scripts/{,*/}*.js'],
tasks: ['newer:jshint:all'],
options: {
livereload: '<%= connect.options.livereload %>'
}
},
jsTest: {
files: ['test/spec/{,*/}*.js'],
tasks: ['newer:jshint:test', 'karma']
},
compass: {
files: ['<%= yeoman.app %>/styles/{,*/}*.{scss,sass}'],
tasks: ['compass:server', 'autoprefixer']
},
gruntfile: {
files: ['Gruntfile.js']
},
livereload: {
options: {
livereload: '<%= connect.options.livereload %>'
},
files: [
'<%= yeoman.app %>/{,*/}*.html',
'.tmp/styles/{,*/}*.css',
'<%= yeoman.app %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}'
]
}
},
// The actual grunt server settings
connect: {
options: {
port: 9000,
// Change this to '0.0.0.0' to access the server from outside.
hostname: 'localhost',
livereload: 35729
},
proxies: [
{
context: '/api',
host: 'localhost',
port: 3000
}
],
livereload: {
options: {
open: true,
middleware: function (connect, options) {
if (!Array.isArray(options.base)) {
options.base = [options.base];
}
// Setup the proxy
var middlewares = [
// Redirect anything that's not a file or an API call to /index.html.
// This allows HTML5 pushState to work on page reloads.
modRewrite(['!/api|/assets|\\..+$ /index.html']),
require('grunt-connect-proxy/lib/utils').proxyRequest,
connect.static('.tmp'),
connect().use(
'/bower_components',
connect.static('./bower_components')
),
connect.static(appConfig.app)
];
// Make directory browse-able.
var directory = options.directory || options.base[options.base.length - 1];
middlewares.push(connect.directory(directory));
return middlewares;
}
}
},
test: {
options: {
port: 9001,
middleware: function (connect, options) {
if (!Array.isArray(options.base)) {
options.base = [options.base];
}
// Setup the proxy
var middlewares = [
require('grunt-connect-proxy/lib/utils').proxyRequest,
connect.static('.tmp'),
connect().use(
'/bower_components',
connect.static('./bower_components')
),
connect.static(appConfig.app)
];
// Make directory browse-able.
var directory = options.directory || options.base[options.base.length - 1];
middlewares.push(connect.directory(directory));
return middlewares;
}
},
appendProxies: false,
proxies: [
{
context: '/api',
host: 'localhost',
port: 3001
}
]
},
dist: {
options: {
open: true,
base: '<%= yeoman.dist %>'
}
}
},
// Make sure code styles are up to par and there are no obvious mistakes
jshint: {
options: {
jshintrc: '.jshintrc',
reporter: require('jshint-stylish')
},
all: {
src: [
'Gruntfile.js',
'<%= yeoman.app %>/scripts/{,*/}*.js'
]
},
test: {
options: {
jshintrc: 'test/.jshintrc'
},
src: ['test/spec/{,*/}*.js']
}
},
// Empties folders to start fresh
clean: {
dist: {
files: [{
dot: true,
src: [
'.tmp',
'<%= yeoman.dist %>/{,*/}*',
'!<%= yeoman.dist %>/.git*'
]
}]
},
server: '.tmp'
},
// Add vendor prefixed styles
autoprefixer: {
options: {
browsers: ['last 1 version']
},
dist: {
files: [{
expand: true,
cwd: '.tmp/styles/',
src: '{,*/}*.css',
dest: '.tmp/styles/'
}]
}
},
// Automatically inject Bower components into the app
wiredep: {
options: {
},
app: {
src: ['<%= yeoman.app %>/index.html'],
ignorePath: /\.\.\//
},
sass: {
src: ['<%= yeoman.app %>/styles/{,*/}*.{scss,sass}'],
ignorePath: /(\.\.\/){1,2}bower_components\//
}
},
// Compiles Sass to CSS and generates necessary files if requested
compass: {
options: {
sassDir: '<%= yeoman.app %>/styles',
cssDir: '.tmp/styles',
generatedImagesDir: '.tmp/images/generated',
imagesDir: '<%= yeoman.app %>/images',
javascriptsDir: '<%= yeoman.app %>/scripts',
importPath: './bower_components',
httpImagesPath: '/images',
httpGeneratedImagesPath: '/images/generated',
httpFontsPath: '/styles/fonts',
relativeAssets: false,
assetCacheBuster: false,
raw: 'Sass::Script::Number.precision = 10\n'
},
dist: {
options: {
generatedImagesDir: '<%= yeoman.dist %>/images/generated'
}
},
server: {
options: {
debugInfo: true
}
}
},
// Renames files for browser caching purposes
filerev: {
dist: {
src: [
'<%= yeoman.dist %>/scripts/{,*/}*.js',
'<%= yeoman.dist %>/styles/{,*/}*.css',
'<%= yeoman.dist %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}',
'<%= yeoman.dist %>/styles/fonts/*'
]
}
},
// Reads HTML for usemin blocks to enable smart builds that automatically
// concat, minify and revision files. Creates configurations in memory so
// additional tasks can operate on them
useminPrepare: {
html: '<%= yeoman.app %>/index.html',
options: {
dest: '<%= yeoman.dist %>',
flow: {
html: {
steps: {
js: ['concat', 'uglifyjs'],
css: ['cssmin']
},
post: {}
}
}
}
},
// Performs rewrites based on filerev and the useminPrepare configuration
usemin: {
html: ['<%= yeoman.dist %>/{,*/}*.html'],
css: ['<%= yeoman.dist %>/styles/{,*/}*.css'],
options: {
assetsDirs: ['<%= yeoman.dist %>','<%= yeoman.dist %>/images']
}
},
// The following *-min tasks will produce minified files in the dist folder
// By default, your `index.html`'s <!-- Usemin block --> will take care of
// minification. These next options are pre-configured if you do not wish
// to use the Usemin blocks.
// cssmin: {
// dist: {
// files: {
// '<%= yeoman.dist %>/styles/main.css': [
// '.tmp/styles/{,*/}*.css'
// ]
// }
// }
// },
// uglify: {
// dist: {
// files: {
// '<%= yeoman.dist %>/scripts/scripts.js': [
// '<%= yeoman.dist %>/scripts/scripts.js'
// ]
// }
// }
// },
// concat: {
// dist: {}
// },
imagemin: {
dist: {
files: [{
expand: true,
cwd: '<%= yeoman.app %>/images',
src: '{,*/}*.{png,jpg,jpeg,gif}',
dest: '<%= yeoman.dist %>/images'
}]
}
},
svgmin: {
dist: {
files: [{
expand: true,
cwd: '<%= yeoman.app %>/images',
src: '{,*/}*.svg',
dest: '<%= yeoman.dist %>/images'
}]
}
},
htmlmin: {
dist: {
options: {
collapseWhitespace: true,
conservativeCollapse: true,
collapseBooleanAttributes: true,
removeCommentsFromCDATA: true,
removeOptionalTags: true
},
files: [{
expand: true,
cwd: '<%= yeoman.dist %>',
src: ['*.html', 'views/{,*/}*.html'],
dest: '<%= yeoman.dist %>'
}]
}
},
// ngmin tries to make the code safe for minification automatically by
// using the Angular long form for dependency injection. It doesn't work on
// things like resolve or inject so those have to be done manually.
ngmin: {
dist: {
files: [{
expand: true,
cwd: '.tmp/concat/scripts',
src: '*.js',
dest: '.tmp/concat/scripts'
}]
}
},
// Replace Google CDN references
cdnify: {
dist: {
html: ['<%= yeoman.dist %>/*.html']
}
},
// Copies remaining files to places other tasks can use
copy: {
dist: {
files: [{
expand: true,
dot: true,
cwd: '<%= yeoman.app %>',
dest: '<%= yeoman.dist %>',
src: [
'*.{ico,png,txt}',
'.htaccess',
'*.html',
'views/{,*/}*.html',
'images/{,*/}*.{webp}',
'fonts/*'
]
}, {
expand: true,
cwd: '.tmp/images',
dest: '<%= yeoman.dist %>/images',
src: ['generated/*']
}, {
expand: true,
cwd: '.',
src: 'bower_components/bootstrap-sass-official/assets/fonts/bootstrap/*',
dest: '<%= yeoman.dist %>'
}]
},
styles: {
expand: true,
cwd: '<%= yeoman.app %>/styles',
dest: '.tmp/styles/',
src: '{,*/}*.css'
}
},
// Run some tasks in parallel to speed up the build process
concurrent: {
server: [
'compass:server'
],
test: [
'compass'
],
dist: [
'compass:dist',
'imagemin',
'svgmin'
]
},
// Test settings
karma: {
unit: {
configFile: 'test/karma.conf.js',
singleRun: true
}
}
});
grunt.registerTask('serve', 'Compile then start a connect web server', function (target) {
if (target === 'dist') {
return grunt.task.run(['build', 'connect:dist:keepalive']);
}
grunt.task.run([
'clean:server',
'wiredep',
'railsServer:development',
'concurrent:server',
'autoprefixer',
'configureProxies',
'connect:livereload',
'watch'
]);
});
grunt.registerTask('server', 'DEPRECATED TASK. Use the "serve" task instead', function (target) {
grunt.log.warn('The `server` task has been deprecated. Use `grunt serve` to start a server.');
grunt.task.run(['serve:' + target]);
});
grunt.registerTask('test', [
'clean:server',
'wiredep',
'railsServer:test',
'concurrent:test',
'autoprefixer',
'configureProxies:test',
'connect:test',
'protractor:run'
]);
grunt.registerTask('build', [
'clean:dist',
'wiredep',
'useminPrepare',
'concurrent:dist',
'autoprefixer',
'concat',
'ngmin',
'copy:dist',
'cdnify',
'cssmin',
'uglify',
'filerev',
'usemin',
'htmlmin'
]);
grunt.registerTask('default', [
'newer:jshint',
'test',
'build'
]);
grunt.registerTask('heroku:production', 'build');
grunt.loadNpmTasks('grunt-connect-proxy');
grunt.loadNpmTasks('grunt-protractor-runner');
grunt.loadNpmTasks('grunt-rails-server');
};
Production Page Reloads
We do basically the same thing in production. We just achieve it in a different way. First, add the rack-rewrite gem to your Gemfile.
# Gemfile
gem 'rack-rewrite'
Then bundle install, of course.
$ bundle install
Then you’ll add a redirect rule to your config.ru that looks like this:
# config.ru
# This file is used by Rack-based servers to start the application.
use Rack::Rewrite do
rewrite %r{^(?!.*(api|\.)).*$}, '/index.html'
end
require ::File.expand_path('../config/environment', __FILE__)
run Rails.application
Now you’re in business. Enjoy!