Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,36 @@ Or with addons:
//= require react-with-addons
```


#### React Helper & Server Rendering

`react_component` is a helper that will let you pass in data from ruby code and have it rendered via React. This rendering can be done on the client (default), or on the server, which is handy for SEO.

```
<%= react_component "Book", {data: book } %>
```

For server prerendering you need to include the `react_ujs` javascript component to wire up the prendered content. So:

```
<%= react_component "Book", {data: book }, {prerender: true} %>
```

In your main `.js` file:

```
//= require react_ujs
```

And in `config.rb`:

```
after_configuration do
sprockets.append_path File.dirname(Middleman::React::react_ujs_path)
end
```


#### A note on versioning

The version for this gem will reflect that of the underlying version of `react-source`, meaning that using `0.12.1` of this gem will give you version `0.12.1` of React. This is the same approach that `react-rails` takes.
Expand Down
7 changes: 7 additions & 0 deletions fixtures/app/config.rb
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
set :js_dir, 'assets/javascripts'

activate :react

after_configuration do
sprockets.append_path File.dirname(::React::Source.bundled_path_for('react.js'))
sprockets.append_path File.dirname(Middleman::React::react_ujs_path)
end
3 changes: 3 additions & 0 deletions fixtures/app/source/assets/javascripts/all.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//= require react
//= require react_ujs
//= require ./components.js
7 changes: 7 additions & 0 deletions fixtures/app/source/assets/javascripts/components.js.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
var HelloWorld = React.createClass({
render: function() {
return (
<h1>Hello {this.props}</h1>
);
}
});
2 changes: 2 additions & 0 deletions fixtures/app/source/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<%= javascript_include_tag 'all' %>
<%= react_component "HelloWorld", { data: "World" }, {prerender: true} %>
104 changes: 104 additions & 0 deletions javascript/react_ujs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*globals React, Turbolinks*/

// Unobtrusive scripting adapter for React
;(function(document, window) {
// jQuery is optional. Use it to support legacy browsers.
var $ = (typeof window.jQuery !== 'undefined') && window.jQuery;

// create the namespace
window.ReactRailsUJS = {
CLASS_NAME_ATTR: 'data-react-class',
PROPS_ATTR: 'data-react-props',
RAILS_ENV_DEVELOPMENT: true, //<%= Rails.env == "development" %>,
// helper method for the mount and unmount methods to find the
// `data-react-class` DOM elements
findDOMNodes: function(searchSelector) {
// we will use fully qualified paths as we do not bind the callbacks
var selector;
if (typeof searchSelector === 'undefined') {
var selector = '[' + window.ReactRailsUJS.CLASS_NAME_ATTR + ']';
} else {
var selector = searchSelector + ' [' + window.ReactRailsUJS.CLASS_NAME_ATTR + ']';
}

if ($) {
return $(selector);
} else {
return document.querySelectorAll(selector);
}
},

mountComponents: function(searchSelector) {
var nodes = window.ReactRailsUJS.findDOMNodes(searchSelector);

for (var i = 0; i < nodes.length; ++i) {
var node = nodes[i];
var className = node.getAttribute(window.ReactRailsUJS.CLASS_NAME_ATTR);

// Assume className is simple and can be found at top-level (window).
// Fallback to eval to handle cases like 'My.React.ComponentName'.
var constructor = window[className] || eval.call(window, className);
var propsJson = node.getAttribute(window.ReactRailsUJS.PROPS_ATTR);
var props = propsJson && JSON.parse(propsJson);

React.render(React.createElement(constructor, props), node);
}
},

unmountComponents: function(searchSelector) {
var nodes = window.ReactRailsUJS.findDOMNodes(searchSelector);

for (var i = 0; i < nodes.length; ++i) {
var node = nodes[i];

React.unmountComponentAtNode(node);
}
}
};

// functions not exposed publicly
function handleTurbolinksEvents () {
var handleEvent;
var unmountEvent;

if ($) {
handleEvent = function(eventName, callback) {
$(document).on(eventName, callback);
};

} else {
handleEvent = function(eventName, callback) {
document.addEventListener(eventName, callback);
};
}

if (Turbolinks.EVENTS) {
unmountEvent = Turbolinks.EVENTS.BEFORE_UNLOAD;
} else {
unmountEvent = 'page:receive';
Turbolinks.pagesCached(0);

if (window.ReactRailsUJS.RAILS_ENV_DEVELOPMENT) {
console.warn('The Turbolinks cache has been disabled (Turbolinks >= 2.4.0 is recommended). See https://github.com/reactjs/react-rails/issues/87 for more information.');
}
}
handleEvent('page:change', function() {window.ReactRailsUJS.mountComponents()});
handleEvent(unmountEvent, function() {window.ReactRailsUJS.unmountComponents()});
}

function handleNativeEvents() {
if ($) {
$(function() {window.ReactRailsUJS.mountComponents()});
$(window).unload(function() {window.ReactRailsUJS.unmountComponents()});
} else {
document.addEventListener('DOMContentLoaded', function() {window.ReactRailsUJS.mountComponents()});
window.addEventListener('unload', function() {window.ReactRailsUJS.unmountComponents()});
}
}

if (typeof Turbolinks !== 'undefined' && Turbolinks.supported) {
handleTurbolinksEvents();
} else {
handleNativeEvents();
}
})(document, window);
8 changes: 8 additions & 0 deletions lib/middleman-react/extension.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,30 @@
require 'middleman-core'
require 'middleman-react/jsx'
require 'middleman-react/jsx/template'
require 'middleman-react/helpers'

module Middleman
module React
# Middleman extension entry point
class Extension < Middleman::Extension
option :harmony, false, 'The option of harmony'
option :strip_types, false, 'The option of stripTypes'
option :components, ['components.js'], 'List of files for precompiling'
option :replay_console, true, 'Replay console for prerendered components'

def initialize(app, options_hash = {}, &block)
super

Middleman::React::Template.harmony = options[:harmony]
Middleman::React::Template.strip_types = options[:strip_types]

Middleman::React::Renderer.components = options[:components]
Middleman::React::Renderer.replay_console = options[:replay_console]

::Tilt.register 'jsx', Middleman::React::Template
::Sprockets.register_engine 'jsx', Middleman::React::Template

app.helpers Middleman::React::Helpers
end
end
end
Expand Down
33 changes: 33 additions & 0 deletions lib/middleman-react/helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
require 'middleman-react/renderer'

module Middleman
module React
# Helper method to render react components
module Helpers
# rubocop:disable Metrics/MethodLength
def react_component(name, args = {}, options = {}, &block)
options = { tag: options } if options.is_a?(Symbol)
content = ''

if options[:prerender]
content = Middleman::React::Renderer.render(self, name, args)
end

html_options = options.reverse_merge(data: {})
html_options[:data].tap do |data|
data[:react_class] = name
unless args.empty?
data[:react_props] = Middleman::React::Renderer.react_props(args)
end
end

html_tag = html_options[:tag] || :div

# remove internally used properties so they aren't rendered to DOM
html_options.except!(:tag, :prerender)

content_tag(html_tag, content, html_options, &block)
end
end
end
end
105 changes: 105 additions & 0 deletions lib/middleman-react/renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
module Middleman
# Handles server-side react rendering
module React
def self.react_ujs_path
File.expand_path('../../../javascript/react_ujs.js', __FILE__)
end

# The rendering logic
class Renderer
# For when we get a JS error
class PrerenderError < RuntimeError
def initialize(component_name, props, js_message)
message = [
"Encountered error \"#{js_message}\" when prerendering" \
" #{component_name} with #{props}",
js_message.backtrace.join("\n")].join("\n")
super(message)
end
end

cattr_accessor :components
cattr_accessor :replay_console

# rubocop:disable Metrics/LineLength, Metrics/MethodLength, ClassVars
def self.render(app, component, args = {})
context = ExecJS.compile(combined_js(app))
begin
react_props = React::Renderer.react_props(args)
jscode = <<-JS
(function () {
var result = React.renderToString(React.createElement(#{component}, #{react_props}));
#{@@replay_console ? Console.replay_as_script_js : ''}
return result;
})()
JS
context.eval(jscode).html_safe
rescue ExecJS::ProgramError => e
raise PrerenderError.new(component, react_props, e)
end
end
# rubocop:enable Metrics/LineLength, Metrics/MethodLength

def self.react_js
@@react_js ||= File.read(::React::Source.bundled_path_for('react.js'))
end

def self.components_js(app)
@@components.collect do |comp|
app.sprockets[comp].source
end.join('\n')
end

def self.combined_js(app)
@@combined_js = <<-JS
var global = global || this;
var self = self || this;
var window = window || this;
#{Console.polyfill_js}
#{react_js};
React = global.React;
#{components_js(app)};
JS
end

def self.react_props(args = {})
if args.is_a? String
args
else
args.to_json
end
end
end

# Overwrite global `console` object with something that can capture
# messages to return to client later for debugging
class Console
# rubocop:disable Metrics/LineLength, Metrics/MethodLength
def self.polyfill_js
<<-JS
var console = { history: [] };
['error', 'log', 'info', 'warn'].forEach(function (fn) {
console[fn] = function () {
console.history.push({level: fn, arguments: Array.prototype.slice.call(arguments)});
};
});
JS
end

def self.replay_as_script_js
<<-JS
(function (history) {
if (history && history.length > 0) {
result += '\\n<scr'+'ipt>';
history.forEach(function (msg) {
result += '\\nconsole.' + msg.level + '.apply(console, ' + JSON.stringify(msg.arguments) + ');';
});
result += '\\n</scr'+'ipt>';
}
})(console.history);
JS
end
# rubocop:enable Metrics/LineLength, Metrics/MethodLength
end
end
end
1 change: 1 addition & 0 deletions spec/features/jsx.feature
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Feature: Transforming JSX into Javascript
Then the stdout from "cat assets/javascripts/plain_jsx.js" should contain exactly:
"""
/** @jsx React.DOM */

React.createElement("div", null);

"""
Expand Down
12 changes: 12 additions & 0 deletions spec/features/prerender.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Feature: Prerendering a component
Background:
Given a fixture app "app"
Given a successfully built app at "app"

Scenario: Rendering Helloworld
When I cd to "build"
Then the following files should exist:
| index.html |
When I run `cat index.html`
Then the output should contain "Hello </span>"
And the output should contain "World</span>"