Progressive rendering for Rails.
A typical Rails client-side profile looks something like this:
This is highly suboptimal. Many resources, such as external stylesheets, are completely static and could be loaded by the client while it's waiting for the server response.
The trick is to stream the response--flushing the markup for the static resources to the client before it has rendered the rest of the page. In addition to being able to render styles and images earlier, the browser can download javascripts, making the page responsive to input events sooner.
The main barrier to this in Rails is that layouts are rendered before the content of the page. The control flow must thus be altered to render the page in the order the client needs to receive it - layout first.
With streaming, your profiles can look more like this:
Just add the template_streaming
gem to your application, and add a stream
call for the actions you'd like to stream. For example, to stream just the
index
action of your HomeController
, it would look like this:
class HomeController
stream :only => :index
def index
...
end
end
To stream everything, just add stream
to your ApplicationController
.
Now you may pepper flush
calls strategically throughout your views to force a
flush, such as just after the stylesheet and javascript tags. flush
may occur
in both templates and their layouts.
The API is simple, but it's important to understand the change in control flow
when a template is streamed. A controller's render
no longer results in
rendering templates immediately; instead, response.body
is set to a
StreamingBody
object which will render the template when the server calls
#each
on the body after the action returns, as per the Rack specification.
This has several implications:
- Anything that needs to inspect or modify the body should be moved to a middleware.
- Modifications to cookies (this includes the flash and session if using the cookie store) must not be made in the view. In fact, these objects will be frozen when streaming.
- An exception during rendering cannot result in a 500 response, as the headers will have already been sent. Instead, the innermost partial which contains an error will simply render nothing, and error information is injected into the foot of the page in development mode.
flush
- flush what has been rendered in the current template out to the client immediately.push(data)
- send the given data to the client immediately.
Template Streaming currently only supports Rails 2.3.11. Rails 3.0 support is planned in the near future. Rails 3.1 will ship with support for streaming. This gem will be updated to meet the API of Rails 3.1 as it evolves, to help you migrate.
Streaming also requires a web server that does not buffer Rack responses. It has
been tested successfully with Passenger, Unicorn,
and Mongrel. Note that Unicorn requires the :tcp_nopush => false
configuration option. Thin is only supported if the
Event Machine Flush gem is installed. WEBrick does
not support streaming. Please send me your experiences with other
web servers!
Class methods:
-
stream
- stream responses for these actions. Takes:only
or:except
options, likebefore_filter
. -
when_streaming_template
- registers a callback to be called duringrender
when rendering progressively. This is before the body is rendered, or any data is sent to the client.
Instance methods:
render
has been modified to accept a:stream
option. If true, the response will be streamed, otherwise it won't. This overrides the setting set by thestream
method above.
As mentioned above, headers are sent to the client before view rendering starts, which means it's not possible to send an error response in the event of an uncaught exception. Instead, the innermost template which raised the error simply renders nothing. This has the added advantage of minimizing the impact on your visitors, as the rest of the page will render fine.
When an error is swallowed like this, it is passed to an error hander callback, which you can set as follows.
TemplateStreaming.on_streaming_error do |controller, exception|
...
end
This is where you should hook in your error notification system. Errors are also logged to the application log.
In addition, in development mode, error information is injected into the foot of the page. This is presented over the top of the rendered page, so the result looks much like when not streaming.
Conventional wisdom says to put your external stylesheets in the HEAD of your page, and your external javascripts at the bottom of the BODY (markup in HAML):
!!! 5
%html
%head
= stylesheet_link_tag 'one'
= stylesheet_link_tag 'two'
- flush
%body
= yield
= javascript_include_tag 'one'
= javascript_include_tag 'two'
When streaming, however, you can do better: put the javascripts at the top of the page too, and fetch them asynchronously. This can be done by appending a script tag to the HEAD of the page in a small piece of inline javascript:
!!! 5
%html
%head
= stylesheet_link_tag 'one'
= stylesheet_link_tag 'two'
= javascript_tag do
= File.read(Rails.public_path + '/javascripts/get_script.js')
$.getScript('#{javascript_path('jquery')}');
$.getScript('#{javascript_path('application')}');
%body
- flush
= yield
//
// Credit: Sam Cole [https://gist.github.com/364746]
//
window.$ = {
getScript: function(script_src, callback) {
var done = false;
var head = document.getElementsByTagName("head")[0] || document.documentElement;
var script = document.createElement("script");
script.src = script_src;
script.onload = script.onreadystatechange = function() {
if ( !done && (!this.readyState ||
this.readyState === "loaded" || this.readyState === "complete") ) {
if(callback) callback();
// Handle memory leak in IE
script.onload = script.onreadystatechange = null;
if ( head && script.parentNode ) {
head.removeChild( script );
}
done = true;
}
};
head.insertBefore( script, head.firstChild );
}
};
If you have inline javascript that depends on the fetched scripts, you'll need to delay its execution until the scripts have been run. You can do this by wrapping the javascript in a function, with a guard which will delay execution until the script is loaded, unless the script has already been loaded. Example:
!!! 5
%html
%head
= stylesheet_link_tag 'one'
= stylesheet_link_tag 'two'
= javascript_tag do
= File.read(Rails.public_path + '/javascripts/get_script.js')
$.getScript('#{javascript_path('jquery')}', function() {
window.script_loaded = 1;
// If the inline code has been loaded (but not yet run), run it
// now. Otherwise, it will be run immediately when it's available.
if (window.inline)
inline();
});
%body
- flush
= yield
- javascript_tag do
window.inline() {
// ... inline javascript code ...
}
// If the script is already loaded, run it now. Otherwise, the callback
// above will run it after the script is loaded.
if (window.script_loaded)
inline();
//
// Credit: Sam Cole [https://gist.github.com/364746]
//
window.$ = {
getScript: function(script_src, callback) {
var done = false;
var head = document.getElementsByTagName("head")[0] || document.documentElement;
var script = document.createElement("script");
script.src = script_src;
script.onload = script.onreadystatechange = function() {
if ( !done && (!this.readyState ||
this.readyState === "loaded" || this.readyState === "complete") ) {
if(callback) callback();
// Handle memory leak in IE
script.onload = script.onreadystatechange = null;
if ( head && script.parentNode ) {
head.removeChild( script );
}
done = true;
}
};
head.insertBefore( script, head.firstChild );
}
};
- Bug reports
- Source
- Patches: Fork on Github, send pull request.
- Include tests where practical.
- Leave the version alone, or bump it in a separate commit.
Copyright (c) George Ogata. See LICENSE for details.