diff --git a/README.md b/README.md index 6e040a0..2451737 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/fixtures/app/config.rb b/fixtures/app/config.rb index e059c70..d1d7adb 100644 --- a/fixtures/app/config.rb +++ b/fixtures/app/config.rb @@ -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 \ No newline at end of file diff --git a/fixtures/app/source/assets/javascripts/all.js b/fixtures/app/source/assets/javascripts/all.js new file mode 100644 index 0000000..fda0750 --- /dev/null +++ b/fixtures/app/source/assets/javascripts/all.js @@ -0,0 +1,3 @@ +//= require react +//= require react_ujs +//= require ./components.js \ No newline at end of file diff --git a/fixtures/app/source/assets/javascripts/components.js.jsx b/fixtures/app/source/assets/javascripts/components.js.jsx new file mode 100644 index 0000000..7fbb383 --- /dev/null +++ b/fixtures/app/source/assets/javascripts/components.js.jsx @@ -0,0 +1,7 @@ +var HelloWorld = React.createClass({ + render: function() { + return ( +

Hello {this.props}

+ ); + } +}); \ No newline at end of file diff --git a/fixtures/app/source/index.html.erb b/fixtures/app/source/index.html.erb new file mode 100644 index 0000000..69387c4 --- /dev/null +++ b/fixtures/app/source/index.html.erb @@ -0,0 +1,2 @@ +<%= javascript_include_tag 'all' %> +<%= react_component "HelloWorld", { data: "World" }, {prerender: true} %> \ No newline at end of file diff --git a/javascript/react_ujs.js b/javascript/react_ujs.js new file mode 100644 index 0000000..583f3ed --- /dev/null +++ b/javascript/react_ujs.js @@ -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); diff --git a/lib/middleman-react/extension.rb b/lib/middleman-react/extension.rb index 32638c2..de7a501 100644 --- a/lib/middleman-react/extension.rb +++ b/lib/middleman-react/extension.rb @@ -3,6 +3,7 @@ require 'middleman-core' require 'middleman-react/jsx' require 'middleman-react/jsx/template' +require 'middleman-react/helpers' module Middleman module React @@ -10,6 +11,8 @@ module React 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 @@ -17,8 +20,13 @@ def initialize(app, options_hash = {}, &block) 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 diff --git a/lib/middleman-react/helpers.rb b/lib/middleman-react/helpers.rb new file mode 100644 index 0000000..c369f1a --- /dev/null +++ b/lib/middleman-react/helpers.rb @@ -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 diff --git a/lib/middleman-react/renderer.rb b/lib/middleman-react/renderer.rb new file mode 100644 index 0000000..5a5dd2f --- /dev/null +++ b/lib/middleman-react/renderer.rb @@ -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'; + history.forEach(function (msg) { + result += '\\nconsole.' + msg.level + '.apply(console, ' + JSON.stringify(msg.arguments) + ');'; + }); + result += '\\n'; + } + })(console.history); + JS + end + # rubocop:enable Metrics/LineLength, Metrics/MethodLength + end + end +end diff --git a/spec/features/jsx.feature b/spec/features/jsx.feature index 94714a3..4287e0c 100644 --- a/spec/features/jsx.feature +++ b/spec/features/jsx.feature @@ -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); """ diff --git a/spec/features/prerender.feature b/spec/features/prerender.feature new file mode 100644 index 0000000..702db3e --- /dev/null +++ b/spec/features/prerender.feature @@ -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 " + And the output should contain "World"