Skip to content

Commit c09a0c6

Browse files
committed
Merge pull request #24 from johnthethird/server-rendering
Implement server-side rendering
2 parents a5e60ce + 3026fba commit c09a0c6

File tree

14 files changed

+239
-44
lines changed

14 files changed

+239
-44
lines changed

README.md

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@
77

88
react-rails is a ruby gem which makes it easier to use [React](http://facebook.github.io/react/) and [JSX](http://facebook.github.io/react/docs/jsx-in-depth.html) in your Ruby on Rails application.
99

10-
This is done in 2 ways:
11-
12-
1. making it easy to include `react.js` as part of your dependencies in `application.js`.
13-
2. transforming JSX into regular JS on request, or as part of asset precompilation.
1410

11+
1. Making it easy to include `react.js` as part of your dependencies in `application.js`.
12+
2. Transforming JSX into regular JS on request, or as part of asset precompilation.
13+
3. View helpers to render React components in an unobtrusive style and/or on the server.
1514

1615
## Installation
1716

@@ -53,7 +52,18 @@ Alternatively, you can include it directly as a separate script tag:
5352

5453
To transform your JSX into JS, simply create `.js.jsx` files, and ensure that the file has the `/** @jsx React.DOM */` docblock. These files will be transformed on request, or precompiled as part of the `assets:precompile` task.
5554

56-
### Unobtrusive javascript
55+
CoffeeScript files can also be used, by creating `.js.jsx.coffee` files. You must use this form of the docblock at the top of each file: `###* @jsx React.DOM ###`. We also need to embed JSX inside backticks so CoffeeScript ignores the syntax it doesn't understand. Here's an example:
56+
57+
```coffee
58+
###* @jsx React.DOM ###
59+
60+
Component = React.createClass
61+
render: ->
62+
`<ExampleComponent videos={this.props.videos} />`
63+
```
64+
65+
66+
### Unobtrusive JavaScript
5767

5868
`react_ujs` will call `React.renderComponent` for every element with `data-react-class` attribute. React properties can be specified by `data-react-props` attribute in JSON format. For example:
5969

@@ -76,7 +86,7 @@ To use `react_ujs`, simply `require` it after `react` (and after `turbolinks` if
7686

7787
### Viewer helper
7888

79-
There is a viewer helper method `react_component`. It is designed to work with `react_ujs` and takes React class name, properties, HTML options as arguments:
89+
There is a viewer helper method `react_component`. It is designed to work with `react_ujs` and takes a React class name, properties, and HTML options as arguments:
8090

8191
```ruby
8292
react_component('HelloMessage', :name => 'John')
@@ -93,19 +103,48 @@ react_component('HelloMessage', {:name => 'John'}, {:id => 'hello', :class => 'f
93103
# <span class="foo" id="hello" data-...></span>
94104
```
95105

106+
### Server Rendering
96107

97-
## CoffeeScript
108+
React components can also use the same ExecJS mechanisims in Sprockets to execute JavaScript code on the server, and render React components to HTML to be delivered to the browser, and then the `react_ujs` script will cause the component to be mounted. In this way, users get fast initial page loads and search-engine-friendly pages.
98109

99-
It is possible to use JSX with CoffeeScript. The caveat is that you will still need to include the docblock. Since CoffeeScript doesn't allow `/* */` style comments, we need to do something a little different. We also need to embed JSX inside backticks so CoffeeScript ignores the syntax it doesn't understand. Here's an example:
110+
#### ExecJS
111+
112+
By default, ExecJS will use node.js in an external process to run JS code. Because we will be executing JS on the server in production, an in-process, high-performance JS VM should be used. Simply add the proper one for your platform to your Gemfile:
113+
114+
```ruby
115+
gem "therubyracer", :platforms => :ruby
116+
gem "therubyrhino", :platforms => :jruby
117+
```
118+
119+
#### components.js
120+
121+
In order for us to render your React components, we need to be able to find them and load them into the JS VM. By convention, we look for a `assets/components.js` file through the asset pipeline, and load that. For example:
122+
123+
```sass
124+
// app/assets/javascripts/components.js
125+
//= require_tree ./components
126+
```
127+
128+
This will bring in all files located in the `app/assets/components` directory. You can organize your code however you like, as long as a request for `/assets/components.js` brings in a concatenated file containing all of your React components, and each one has to be available in the global scope (either `window` or `global` can be used). For `.js.jsx` files this is not a problem, but if you are using `.js.jsx.coffee` files then the wrapper function needs to be taken into account:
100129

101130
```coffee
102131
###* @jsx React.DOM ###
103132

104133
Component = React.createClass
105134
render: ->
106135
`<ExampleComponent videos={this.props.videos} />`
136+
137+
window.Component = Component
107138
```
108139

140+
#### View Helper
141+
142+
To take advantage of server rendering, use the same view helper `react_component`, and pass in `:prerender => true` in the `options` hash.
143+
144+
```erb
145+
react_component('HelloMessage', {:name => 'John'}, {:prerender => true})
146+
```
147+
This will return the fully rendered component markup, and as long as you have included the `react_ujs` script in your page, then the component will also be instantiated and mounted on the client.
109148

110149
## Configuring
111150

@@ -126,6 +165,7 @@ end
126165
```
127166

128167
### Add-ons
168+
129169
Beginning with React v0.5, there is another type of build. This build ships with some "add-ons" that might be useful - [take a look at the React documentation for details](http://facebook.github.io/react/docs/addons.html). In order to make these available, we've added another configuration (which defaults to `false`).
130170

131171
```ruby
@@ -134,13 +174,37 @@ MyApp::Application.configure do
134174
end
135175
```
136176

137-
### Changing react.js and JSXTransformer.js versions
177+
### Server Rendering
138178

139-
In some cases you may want to have your `react.js` and `JSXTransformer.js` files come from a different release than the one, that is specified in the `react-rails.gemspec`. To achieve that, you have to manually replace them in your app.
179+
For performance and thread-safety reasons, a pool of JS VMs are spun up on application start, and the size of the pool and the timeout on requesting a VM from the pool are configurable. You can also say where you want to grab the `react.js` code from, and if you want to change the filenames for the components (this should be an array of filenames that will be requested from the asset pipeline and concatenated together.)
140180

141-
react-rails at `0.x` requires React at `0.4+`, or `0.5+` or even higher if you need certain add-ons.
181+
```ruby
182+
# config/environments/application.rb
183+
# These are the defaults if you dont specify any yourself
184+
MyApp::Application.configure do
185+
config.react.max_renderers = 10
186+
config.react.timeout = 20 #seconds
187+
config.react.react_js = lambda {File.read(::Rails.application.assets.resolve('react.js'))}
188+
config.react.component_filenames = ['components.js']
189+
end
190+
191+
```
142192

143-
react-rails at `1.x` requires React at `0.9+`.
193+
## CoffeeScript
194+
195+
It is possible to use JSX with CoffeeScript. The caveat is that you will still need to include the docblock. Since CoffeeScript doesn't allow `/* */` style comments, we need to do something a little different. We also need to embed JSX inside backticks so CoffeeScript ignores the syntax it doesn't understand. Here's an example:
196+
197+
```coffee
198+
###* @jsx React.DOM ###
199+
200+
Component = React.createClass
201+
render: ->
202+
`<ExampleComponent videos={this.props.videos} />`
203+
```
204+
205+
### Changing react.js and JSXTransformer.js versions
206+
207+
In some cases you may want to have your `react.js` and `JSXTransformer.js` files come from a different release than the one, that is specified in the `react-rails.gemspec`. To achieve that, you have to manually replace them in your app.
144208

145209
#### Instructions
146210

@@ -154,4 +218,4 @@ after `config.react.variant`, e.g. you set `config.react.variant = :development`
154218
If you replace `JSXTransformer.js` in production environment, you have to restart your rails instance,
155219
because the jsx compiler context is cached.
156220

157-
Name of the `JSXTransformer.js` file *is case-sensitive*.
221+
Name of the `JSXTransformer.js` file *is case-sensitive*.

lib/react-rails.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
require 'react/jsx'
2+
require 'react/renderer'
23
require 'react/rails'
34

lib/react/rails/railtie.rb

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ class Railtie < ::Rails::Railtie
88
# Sensible defaults. Can be overridden in application.rb
99
config.react.variant = (::Rails.env.production? ? :production : :development)
1010
config.react.addons = false
11+
# Server-side rendering
12+
config.react.max_renderers = 10
13+
config.react.timeout = 20 #seconds
14+
config.react.react_js = lambda {File.read(::Rails.application.assets.resolve('react.js'))}
15+
config.react.component_filenames = ['components.js']
16+
17+
# Watch .jsx files for changes in dev, so we can reload the JS VMs with the new JS code.
18+
initializer "react_rails.add_watchable_files" do |app|
19+
app.config.watchable_files.concat Dir["#{app.root}/app/assets/javascripts/**/*.jsx*"]
20+
end
1121

1222
# run after all initializers to allow sprockets to pick up react.js and
1323
# jsxtransformer.js from end-user to override ours if needed
@@ -37,11 +47,32 @@ class Railtie < ::Rails::Railtie
3747
app.assets.prepend_path dropin_path if dropin_path.exist?
3848

3949
# Allow overriding react files that are based on environment
40-
# e.g. /vendor/assets/react/react.js
50+
# e.g. /vendor/assets/react/development/react.js
4151
dropin_path_env = app.root.join("vendor/assets/react/#{app.config.react.variant}")
4252
app.assets.prepend_path dropin_path_env if dropin_path_env.exist?
53+
end
54+
55+
56+
config.after_initialize do |app|
57+
# Server Rendering
58+
# Concat component_filenames together for server rendering
59+
app.config.react.components_js = app.config.react.component_filenames.map do |filename|
60+
app.assets[filename].to_s
61+
end.join(";")
4362

63+
do_setup = lambda do
64+
cfg = app.config.react
65+
React::Renderer.setup!( cfg.react_js.call, cfg.components_js,
66+
{:size => cfg.size, :timeout => cfg.timeout})
67+
end
68+
69+
do_setup.call
70+
71+
# Reload the JS VMs in dev when files change
72+
ActionDispatch::Reloader.to_prepare(&do_setup)
4473
end
74+
75+
4576
end
4677
end
4778
end

lib/react/rails/view_helper.rb

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,14 @@
11
module React
22
module Rails
33
module ViewHelper
4-
# Render a react component named +name+. Returns a HTML tag and some
5-
# javascript to render the component.
6-
#
7-
# HTML attributes can be specified by +options+. The HTML tag is +div+
8-
# by default and can be changed by +options[:tag]+. If +options+ is a
9-
# symbol, use it as +options[:tag]+.
10-
#
11-
# Static child elements can be rendered by using a block. Be aware that
12-
# they will be replaced once javascript gets executed.
13-
#
14-
# ==== Examples
15-
#
16-
# # // <HelloMessage> defined in a .jsx file:
17-
# # var HelloMessage = React.createClass({
18-
# # render: function() {
19-
# # return <div>{'Hello ' + this.props.name}</div>;
20-
# # }
21-
# # });
22-
# react_component(:HelloMessage, :name => 'John')
23-
#
24-
# # Use <span> instead of <div>:
25-
# react_component(:HelloMessage, {:name => 'John'}, :span)
26-
# react_component(:HelloMessage, {:name => 'John'}, :tag => :span)
27-
#
28-
# # Add HTML attributes:
29-
# react_component(:HelloMessage, {}, {:class => 'c', :id => 'i'})
4+
5+
# Render a UJS-type HTML tag annotated with data attributes, which
6+
# are used by react_ujs to actually instantiate the React component
7+
# on the client.
308
#
31-
# # (ERB) Customize child elements:
32-
# <%= react_component :HelloMessage do -%>
33-
# Loading...
34-
# <% end -%>
359
def react_component(name, args = {}, options = {}, &block)
3610
options = {:tag => options} if options.is_a?(Symbol)
11+
block = Proc.new{React::Renderer.render(name, args)} if options[:prerender] == true
3712

3813
html_options = options.reverse_merge(:data => {})
3914
html_options[:data].tap do |data|
@@ -44,6 +19,7 @@ def react_component(name, args = {}, options = {}, &block)
4419

4520
content_tag(html_tag, '', html_options, &block)
4621
end
22+
4723
end
4824
end
4925
end

lib/react/renderer.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
require 'connection_pool'
2+
3+
module React
4+
class Renderer
5+
6+
cattr_accessor :pool
7+
8+
def self.setup!(react_js, components_js, args={})
9+
args.assert_valid_keys(:size, :timeout)
10+
@@react_js = react_js
11+
@@components_js = components_js
12+
@@pool.shutdown{} if @@pool
13+
@@pool = ConnectionPool.new(:size => args[:size]||10, :timeout => args[:timeout]||20) { self.new }
14+
end
15+
16+
def self.render(component, args={})
17+
@@pool.with do |renderer|
18+
renderer.render(component, args)
19+
end
20+
end
21+
22+
def self.combined_js
23+
@@combined_js ||= <<-CODE
24+
var global = global || this;
25+
#{@@react_js};
26+
React = global.React;
27+
#{@@components_js};
28+
CODE
29+
end
30+
31+
def context
32+
@context ||= ExecJS.compile(self.class.combined_js)
33+
end
34+
35+
def render(component, args={})
36+
jscode = <<-JS
37+
function() {
38+
return React.renderComponentToString(#{component}(#{args.to_json}));
39+
}()
40+
JS
41+
context.eval(jscode).html_safe
42+
# What should be done here? If we are server rendering, and encounter an error in the JS code,
43+
# then log it and continue, which will just render the react ujs tag, and when the browser tries
44+
# to render the component it will most likely encounter the same error and throw to the browser
45+
# console for a better debugging experience.
46+
rescue ExecJS::ProgramError => e
47+
::Rails.logger.error "[React::Renderer] #{e.message}"
48+
end
49+
50+
end
51+
end

react-rails.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Gem::Specification.new do |s|
2424
s.add_dependency 'execjs'
2525
s.add_dependency 'rails', '>= 3.1'
2626
s.add_dependency 'react-source', '0.9.0'
27+
s.add_dependency 'connection_pool'
2728

2829
s.files = Dir[
2930
'lib/**/*',
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
//= require_self
2+
//= require_tree ./components
3+
4+
// This is because we compile this file into a JS VM
5+
// for server rendering, and some components may be
6+
// .coffee and wrapped in a func, so they need a
7+
// global to glom on to.
8+
var self, window, global = global || window || self;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
###* @jsx React.DOM ###
2+
3+
Todo = React.createClass
4+
render: ->
5+
`<li>{this.props.todo}</li>`
6+
7+
# Because Coffee files are in an anonymous function,
8+
# expose it for server rendering tests
9+
global.Todo = Todo
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/** @jsx React.DOM */
2+
3+
TodoList = React.createClass({
4+
getInitialState: function() {
5+
return({mounted: "nope"});
6+
},
7+
componentWillMount: function() {
8+
this.setState({mounted: 'yep'});
9+
},
10+
render: function() {
11+
return (
12+
<ul>
13+
<li id='status'>{this.state.mounted}</li>
14+
{this.props.todos.map(function(todo, i) {
15+
return (<Todo key={i} todo={todo} />)
16+
})}
17+
</ul>
18+
)
19+
}
20+
})
21+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class ServerController < ApplicationController
2+
def show
3+
@todos = %w{todo1 todo2 todo3}
4+
end
5+
end
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<%= react_component "TodoList", {:todos => @todos}, :prerender => true %>

test/dummy/config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
Dummy::Application.routes.draw do
22
resources :pages, :only => [:show]
3+
resources :server, :only => [:show]
34
end

test/react_renderer_test.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
require 'test_helper'
2+
3+
class ReactRendererTest < ActiveSupport::TestCase
4+
5+
test 'Server rendering class directly' do
6+
result = React::Renderer.render "TodoList", :todos => %w{todo1 todo2 todo3}
7+
assert_match /todo1.*todo2.*todo3/, result
8+
assert_match /data-react-checksum/, result
9+
end
10+
11+
end

0 commit comments

Comments
 (0)