From 799ffceaa4c38743b7a21982d2f8678dc42947da Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 27 Apr 2015 21:00:53 -0700 Subject: [PATCH 1/9] feat(ServerRendering) add ServerRendering and SprocketsRenderer --- Guardfile | 6 +++ lib/react-rails.rb | 1 + lib/react/server_rendering.rb | 27 +++++++++++ .../server_rendering/sprockets_renderer.rb | 46 ++++++++++++++++++ react-rails.gemspec | 2 + .../sprockets_renderer_test.rb | 13 +++++ test/react/server_rendering_test.rb | 48 +++++++++++++++++++ 7 files changed, 143 insertions(+) create mode 100644 Guardfile create mode 100644 lib/react/server_rendering.rb create mode 100644 lib/react/server_rendering/sprockets_renderer.rb create mode 100644 test/react/server_rendering/sprockets_renderer_test.rb create mode 100644 test/react/server_rendering_test.rb diff --git a/Guardfile b/Guardfile new file mode 100644 index 000000000..7f999bb18 --- /dev/null +++ b/Guardfile @@ -0,0 +1,6 @@ +guard :minitest do + # with Minitest::Unit + watch(%r{^test/(.*)\/?(.*)_test\.rb$}) + watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}#{m[2]}_test.rb" } + watch(%r{^test/test_helper\.rb$}) { 'test' } +end diff --git a/lib/react-rails.rb b/lib/react-rails.rb index 9e75b37ab..e7ec5b8b5 100644 --- a/lib/react-rails.rb +++ b/lib/react-rails.rb @@ -2,4 +2,5 @@ require 'react/renderer' require 'react/rails' require 'react/console' +require 'react/server_rendering' diff --git a/lib/react/server_rendering.rb b/lib/react/server_rendering.rb new file mode 100644 index 000000000..27ead2c6b --- /dev/null +++ b/lib/react/server_rendering.rb @@ -0,0 +1,27 @@ +require 'connection_pool' +require 'react/server_rendering/sprockets_renderer' + +module React + module ServerRendering + mattr_accessor :renderer, :renderer_options, + :pool_size, :pool_timeout + + self.pool_size = 10 + self.pool_timeout = 20 + + def self.reset_pool + options = {size: pool_size, timeout: pool_timeout} + @@pool = ConnectionPool.new(options) { create_renderer } + end + + def self.render(component_name, props) + @@pool.with do |renderer| + renderer.render(component_name, props) + end + end + + def self.create_renderer + renderer.new(renderer_options) + end + end +end \ No newline at end of file diff --git a/lib/react/server_rendering/sprockets_renderer.rb b/lib/react/server_rendering/sprockets_renderer.rb new file mode 100644 index 000000000..f6af0f011 --- /dev/null +++ b/lib/react/server_rendering/sprockets_renderer.rb @@ -0,0 +1,46 @@ +module React + module ServerRendering + class SprocketsRenderer + def initialize(options={}) + filenames = options.fetch(:setup, ["react.js", "components.js"]) + @replay_console = options.fetch(:replay_console, true) + + js_code = SetupJavascript.new(filenames).to_s + @context = ExecJS.compile(js_code) + end + + def render(component_name, props) + if !props.is_a?(String) + props = props.to_json + end + js_code = <<-JS + (function () { + var result = React.renderToString(React.createElement(#{component_name}, #{props})); + return result; + })() + JS + @context.eval(js_code).html_safe + # handle error + end + + class SetupJavascript + GLOBAL_WRAPPER = <<-JS + var global = global || this; + var self = self || this; + var window = window || this; + JS + def initialize(filenames) + js_code = "" + filenames.each do |filename| + js_code << ::Rails.application.assets[filename].to_s + end + @wrapped_code = GLOBAL_WRAPPER + js_code + end + + def to_s + @wrapped_code + end + end + end + end +end diff --git a/react-rails.gemspec b/react-rails.gemspec index 0ea483636..0e98a5cf3 100644 --- a/react-rails.gemspec +++ b/react-rails.gemspec @@ -18,6 +18,8 @@ Gem::Specification.new do |s| s.add_development_dependency 'bundler', '>= 1.2.2' s.add_development_dependency 'coffee-rails' s.add_development_dependency 'es5-shim-rails', '>= 2.0.5' + s.add_development_dependency 'guard' + s.add_development_dependency 'guard-minitest' s.add_development_dependency 'jbuilder' s.add_development_dependency 'poltergeist', '>= 0.3.3' s.add_development_dependency 'test-unit', '~> 2.5' diff --git a/test/react/server_rendering/sprockets_renderer_test.rb b/test/react/server_rendering/sprockets_renderer_test.rb new file mode 100644 index 000000000..ec2b114fb --- /dev/null +++ b/test/react/server_rendering/sprockets_renderer_test.rb @@ -0,0 +1,13 @@ +require 'test_helper' + +class SprocketsRendererTest < ActiveSupport::TestCase + setup do + @renderer = React::ServerRendering::SprocketsRenderer.new({}) + end + + test '#render returns HTML' do + result = @renderer.render("Todo", {todo: "write tests"}) + # skip reactid & checksum: + assert_match(//, result) + end +end \ No newline at end of file diff --git a/test/react/server_rendering_test.rb b/test/react/server_rendering_test.rb new file mode 100644 index 000000000..d36a3ecb1 --- /dev/null +++ b/test/react/server_rendering_test.rb @@ -0,0 +1,48 @@ +require 'test_helper' + +class NullRenderer + def initialize(options) + # in this case, options is actually a string (just for testing) + @name = options + end + + def render(component_name, props) + "#{@name} rendered #{component_name} with #{props}" + end +end + +class ReactServerRenderingTest < ActiveSupport::TestCase + setup do + React::ServerRendering.renderer_options = "TEST" + React::ServerRendering.renderer = NullRenderer + React::ServerRendering.reset_pool + end + + test '.create_renderer makes a renderer with initialization options' do + mock_renderer = MiniTest::Mock.new + mock_renderer.expect(:new, :fake_renderer, [{mock: true}]) + React::ServerRendering.renderer = mock_renderer + React::ServerRendering.renderer_options = {mock: true} + renderer = React::ServerRendering.create_renderer + assert_equal(:fake_renderer, renderer) + end + + test '.render returns a rendered string' do + props = {"props" => true} + result = React::ServerRendering.render("MyComponent", props) + assert_equal("TEST rendered MyComponent with #{props}", result) + end + + test '.reset_pool forgets old renderers' do + # At first, they use the first options: + assert_match(/^TEST/, React::ServerRendering.render(nil, nil)) + assert_match(/^TEST/, React::ServerRendering.render(nil, nil)) + + # Then change the init options and clear the pool: + React::ServerRendering.renderer_options = "DIFFERENT" + React::ServerRendering.reset_pool + # New renderers are created with the new init options: + assert_match(/^DIFFERENT/, React::ServerRendering.render(nil, nil)) + assert_match(/^DIFFERENT/, React::ServerRendering.render(nil, nil)) + end +end \ No newline at end of file From 381d8c8981db231b4b37ac6cda8a14642d4a43cf Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 27 Apr 2015 21:25:10 -0700 Subject: [PATCH 2/9] feat(SprocketsRenderer) add error handling and console replay --- .../server_rendering/sprockets_renderer.rb | 47 +++++++++++++++---- .../sprockets_renderer_test.rb | 40 +++++++++++++++- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/lib/react/server_rendering/sprockets_renderer.rb b/lib/react/server_rendering/sprockets_renderer.rb index f6af0f011..275ca234c 100644 --- a/lib/react/server_rendering/sprockets_renderer.rb +++ b/lib/react/server_rendering/sprockets_renderer.rb @@ -2,10 +2,10 @@ module React module ServerRendering class SprocketsRenderer def initialize(options={}) - filenames = options.fetch(:setup, ["react.js", "components.js"]) @replay_console = options.fetch(:replay_console, true) - js_code = SetupJavascript.new(filenames).to_s + filenames = options.fetch(:files, ["react.js", "components.js"]) + js_code = SetupJavascript.new(filenames).code @context = ExecJS.compile(js_code) end @@ -13,14 +13,18 @@ def render(component_name, props) if !props.is_a?(String) props = props.to_json end + js_code = <<-JS (function () { var result = React.renderToString(React.createElement(#{component_name}, #{props})); + #{@replay_console ? CONSOLE_REPLAY : ""} return result; })() JS + @context.eval(js_code).html_safe - # handle error + rescue ExecJS::ProgramError => err + raise PrerenderError.new(component_name, props, err) end class SetupJavascript @@ -29,16 +33,43 @@ class SetupJavascript var self = self || this; var window = window || this; JS + + attr_reader :code + def initialize(filenames) - js_code = "" + @code = GLOBAL_WRAPPER + CONSOLE_POLYFILL filenames.each do |filename| - js_code << ::Rails.application.assets[filename].to_s + @code << ::Rails.application.assets[filename].to_s end - @wrapped_code = GLOBAL_WRAPPER + js_code end + end + + CONSOLE_POLYFILL = <<-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 + + CONSOLE_REPLAY = <<-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 - def to_s - @wrapped_code + 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 end diff --git a/test/react/server_rendering/sprockets_renderer_test.rb b/test/react/server_rendering/sprockets_renderer_test.rb index ec2b114fb..1289c771e 100644 --- a/test/react/server_rendering/sprockets_renderer_test.rb +++ b/test/react/server_rendering/sprockets_renderer_test.rb @@ -7,7 +7,45 @@ class SprocketsRendererTest < ActiveSupport::TestCase test '#render returns HTML' do result = @renderer.render("Todo", {todo: "write tests"}) - # skip reactid & checksum: assert_match(//, result) + assert_match(/data-react-checksum/, result) + end + + test '#render accepts strings' do + result = @renderer.render("Todo", {todo: "write more tests"}.to_json) + assert_match(//, result) + end + + test '#render replays console messages' do + result = @renderer.render("TodoListWithConsoleLog", {todos: ["log some messages"]}) + assert_match('console.log.apply(console, ["got initial state"])', result) + assert_match('console.warn.apply(console, ["mounted component"])', result) + assert_match('console.error.apply(console, ["rendered!","foo"])', result) + end + + test '#render console messages can be disabled' do + no_log_renderer = React::ServerRendering::SprocketsRenderer.new({replay_console: false}) + result = no_log_renderer.render("TodoListWithConsoleLog", {todos: ["log some messages"]}) + assert_no_match('console.log.apply(console, ["got initial state"])', result) + assert_no_match('console.warn.apply(console, ["mounted component"])', result) + assert_no_match('console.error.apply(console, ["rendered!","foo"])', result) + end + + test '#render errors include stack traces' do + err = assert_raises React::ServerRendering::SprocketsRenderer::PrerenderError do + @renderer.render("NonExistentComponent", {}) + end + assert_match("ReferenceError", err.to_s) + assert_match("NonExistentComponent", err.to_s, "it names the component") + assert_match(/\n/, err.to_s, "it includes the multi-line backtrace") + end + + test '.new accepts any filenames' do + limited_renderer = React::ServerRendering::SprocketsRenderer.new(files: ["react.js", "components/Todo.js"]) + assert_match("get a real job", limited_renderer.render("Todo", {todo: "get a real job"})) + err = assert_raises React::ServerRendering::SprocketsRenderer::PrerenderError do + limited_renderer.render("TodoList", {todos: []}) + end + assert_match("ReferenceError", err.to_s, "it doesnt load other files") end end \ No newline at end of file From beb61af19b1f0117b6f23aacde12432b010434e6 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 27 Apr 2015 22:02:25 -0700 Subject: [PATCH 3/9] feat(ServerRendering) remove previous Renderer --- lib/react-rails.rb | 2 - lib/react/console.rb | 30 ------- lib/react/rails/railtie.rb | 25 ++---- lib/react/rails/view_helper.rb | 16 ++-- lib/react/renderer.rb | 83 ------------------- .../app/controllers/server_controller.rb | 15 +--- test/react/server_rendering_test.rb | 7 ++ test/react_renderer_test.rb | 59 ------------- 8 files changed, 26 insertions(+), 211 deletions(-) delete mode 100644 lib/react/console.rb delete mode 100644 lib/react/renderer.rb delete mode 100644 test/react_renderer_test.rb diff --git a/lib/react-rails.rb b/lib/react-rails.rb index e7ec5b8b5..fdb8abf93 100644 --- a/lib/react-rails.rb +++ b/lib/react-rails.rb @@ -1,6 +1,4 @@ require 'react/jsx' -require 'react/renderer' require 'react/rails' -require 'react/console' require 'react/server_rendering' diff --git a/lib/react/console.rb b/lib/react/console.rb deleted file mode 100644 index 35050fe97..000000000 --- a/lib/react/console.rb +++ /dev/null @@ -1,30 +0,0 @@ -module React - class Console - def self.polyfill_js - # Overwrite global `console` object with something that can capture messages - # to return to client later for debugging - <<-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 - end -end diff --git a/lib/react/rails/railtie.rb b/lib/react/rails/railtie.rb index 362e3685f..28c9bb170 100644 --- a/lib/react/rails/railtie.rb +++ b/lib/react/rails/railtie.rb @@ -49,24 +49,13 @@ class Railtie < ::Rails::Railtie "react-#{variant}", ].compact.join('-') - # Server Rendering - # Concat component_filenames together for server rendering - app.config.react.components_js = lambda { - app.config.react.component_filenames.map do |filename| - app.assets[filename].to_s - end.join(";") - } - - do_setup = lambda do - cfg = app.config.react - React::Renderer.setup!( cfg.react_js, cfg.components_js, cfg.replay_console, - {:size => cfg.max_renderers, :timeout => cfg.timeout}) - end - - do_setup.call - - # Reload the JS VMs in dev when files change - ActionDispatch::Reloader.to_prepare(&do_setup) + app.config.react.server_renderer_options ||= {} + app.config.react.server_renderer ||= React::ServerRendering::SprocketsRenderer + React::ServerRendering.renderer_options = app.config.react.server_renderer_options + React::ServerRendering.renderer = app.config.react.server_renderer + React::ServerRendering.reset_pool + # Reload renderers in dev when files change + ActionDispatch::Reloader.to_prepare { React::ServerRendering.reset_pool } end end end diff --git a/lib/react/rails/view_helper.rb b/lib/react/rails/view_helper.rb index 5abb55e7a..ad1e08637 100644 --- a/lib/react/rails/view_helper.rb +++ b/lib/react/rails/view_helper.rb @@ -1,28 +1,28 @@ module React module Rails module ViewHelper - # Render a UJS-type HTML tag annotated with data attributes, which # are used by react_ujs to actually instantiate the React component # on the client. - # - def react_component(name, args = {}, options = {}, &block) + def react_component(name, props = {}, options = {}, &block) options = {:tag => options} if options.is_a?(Symbol) - block = Proc.new{concat React::Renderer.render(name, args)} if options[:prerender] + + if options[:prerender] + block = Proc.new{ concat React::ServerRendering.render(name, props) } + end html_options = options.reverse_merge(:data => {}) html_options[:data].tap do |data| data[:react_class] = name - data[:react_props] = React::Renderer.react_props(args) unless args.empty? + data[:react_props] = (props.is_a?(String) ? props : props.to_json) 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, '', html_options, &block) end - end end end diff --git a/lib/react/renderer.rb b/lib/react/renderer.rb deleted file mode 100644 index e1315695c..000000000 --- a/lib/react/renderer.rb +++ /dev/null @@ -1,83 +0,0 @@ -require 'connection_pool' - -module React - class Renderer - - 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 :pool - - def self.setup!(react_js, components_js, replay_console, args={}) - args.assert_valid_keys(:size, :timeout) - @@react_js = react_js - @@components_js = components_js - @@replay_console = replay_console - @@pool.shutdown{} if @@pool - reset_combined_js! - default_pool_options = {:size =>10, :timeout => 20} - @@pool = ConnectionPool.new(default_pool_options.merge(args)) { self.new } - end - - def self.render(component, args={}) - @@pool.with do |renderer| - renderer.render(component, args) - end - end - - def self.react_props(args={}) - if args.is_a? String - args - else - args.to_json - end - end - - def context - @context ||= ExecJS.compile(self.class.combined_js) - end - - def render(component, args={}) - react_props = React::Renderer.react_props(args) - jscode = <<-JS - (function () { - var result = React.renderToString(React.createElement(#{component}, #{react_props})); - #{@@replay_console ? React::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 - - - private - - def self.setup_combined_js - <<-JS - var global = global || this; - var self = self || this; - var window = window || this; - #{React::Console.polyfill_js} - #{@@react_js.call}; - React = global.React; - #{@@components_js.call}; - JS - end - - def self.reset_combined_js! - @@combined_js = setup_combined_js - end - - def self.combined_js - @@combined_js - end - - end -end diff --git a/test/dummy/app/controllers/server_controller.rb b/test/dummy/app/controllers/server_controller.rb index bc1008375..48f4f9757 100644 --- a/test/dummy/app/controllers/server_controller.rb +++ b/test/dummy/app/controllers/server_controller.rb @@ -4,21 +4,14 @@ def show end def console_example - hack_replay_console_config true + React::ServerRendering.renderer_options = {replay_console: true} + React::ServerRendering.reset_pool @todos = %w{todo1 todo2 todo3} end def console_example_suppressed - hack_replay_console_config false + React::ServerRendering.renderer_options = {replay_console: false} + React::ServerRendering.reset_pool @todos = %w{todo1 todo2 todo3} end - - private - def hack_replay_console_config(value) - # Don't do this in your app; just set it how you want it in config/application.rb - cfg = ::Rails.application.config.react - cfg.replay_console = value - React::Renderer.setup!( cfg.react_js, cfg.components_js, cfg.replay_console, - {:size => cfg.max_renderers, :timeout => cfg.timeout}) - end end diff --git a/test/react/server_rendering_test.rb b/test/react/server_rendering_test.rb index d36a3ecb1..4109daaad 100644 --- a/test/react/server_rendering_test.rb +++ b/test/react/server_rendering_test.rb @@ -13,11 +13,18 @@ def render(component_name, props) class ReactServerRenderingTest < ActiveSupport::TestCase setup do + @previous_renderer = React::ServerRendering.renderer + @previous_options = React::ServerRendering.renderer_options React::ServerRendering.renderer_options = "TEST" React::ServerRendering.renderer = NullRenderer React::ServerRendering.reset_pool end + teardown do + React::ServerRendering.renderer = @previous_renderer + React::ServerRendering.renderer_options = @previous_options + end + test '.create_renderer makes a renderer with initialization options' do mock_renderer = MiniTest::Mock.new mock_renderer.expect(:new, :fake_renderer, [{mock: true}]) diff --git a/test/react_renderer_test.rb b/test/react_renderer_test.rb deleted file mode 100644 index 1508dc9f8..000000000 --- a/test/react_renderer_test.rb +++ /dev/null @@ -1,59 +0,0 @@ -require 'test_helper' - -class ReactRendererTest < ActiveSupport::TestCase - - test 'Server rendering class directly' do - result = React::Renderer.render "TodoList", :todos => %w{todo1 todo2 todo3} - assert_match(/todo1.*todo2.*todo3/, result) - assert_match(/data-react-checksum/, result) - end - - test 'Server rendering with an already-encoded json string' do - json_string = Jbuilder.new do |json| - json.todos %w{todo1 todo2 todo3} - end.target! - - result = React::Renderer.render "TodoList", json_string - assert_match(/todo1.*todo2.*todo3/, result) - assert_match(/data-react-checksum/, result) - end - - test 'Rendering does not throw an exception when console log api is used' do - %W(error info log warn).each do |fn| - assert_nothing_raised(ExecJS::ProgramError) do - React::Renderer.pool.checkout.context.eval("console.#{fn}()") - end - end - end - - test 'prerender errors are thrown' do - err = assert_raises React::Renderer::PrerenderError do - React::Renderer.render("NonexistentComponent", {error: true, exists: false}) - end - expected_message_one = 'Encountered error "ReferenceError: Can\'t find variable: NonexistentComponent" when prerendering NonexistentComponent with {"error":true,"exists":false}' - expected_message_two = 'Encountered error "ReferenceError: NonexistentComponent is not defined" when prerendering NonexistentComponent with {"error":true,"exists":false}' - assert (err.message.starts_with?(expected_message_one) || err.message.starts_with?(expected_message_two)) - end - - test 'prerender errors include the error backtrace' do - err = assert_raises React::Renderer::PrerenderError do - React::Renderer.render("NonexistentComponent", {error: true, exists: false}) - end - - assert (err.message.count("\n") > 2), "The error has a multi-line backtrace" - end - - test 'prerender errors are thrown when given a string' do - json_string = Jbuilder.new do |json| - json.error true - json.exists false - end.target! - - err = assert_raises React::Renderer::PrerenderError do - React::Renderer.render("NonexistentComponent", json_string) - end - expected_message_one = 'Encountered error "ReferenceError: Can\'t find variable: NonexistentComponent" when prerendering NonexistentComponent with {"error":true,"exists":false}' - expected_message_two = 'Encountered error "ReferenceError: NonexistentComponent is not defined" when prerendering NonexistentComponent with {"error":true,"exists":false}' - assert (err.message.starts_with?(expected_message_one) || err.message.starts_with?(expected_message_two)) - end -end From e1e319d3d0258c66776141857ccbf8b3519105b5 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 27 Apr 2015 22:14:32 -0700 Subject: [PATCH 4/9] feat(ServerRendering) update readme, fix configs --- README.md | 20 ++++++++------------ lib/react/rails/railtie.rb | 22 +++++++++++++--------- lib/react/server_rendering.rb | 3 --- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0acb98390..4f5f51ce3 100644 --- a/README.md +++ b/README.md @@ -154,18 +154,14 @@ You can configure your pool of JS virtual machines and specify where it should l # config/environments/application.rb # These are the defaults if you dont specify any yourself MyApp::Application.configure do - # renderer pool size: - config.react.max_renderers = 10 - # prerender timeout, in seconds: - config.react.timeout = 20 - # where to get React.js source: - config.react.react_js = lambda { File.read(::Rails.application.assets.resolve('react.js')) } - # array of filenames that will be requested from the asset pipeline - # and concatenated: - config.react.component_filenames = ['components.js'] - # server-side console.log, console.warn, and console.error messages will be replayed on the client - # (you can set this to `true` in config/enviroments/development.rb to replay in development only) - config.react.replay_console = false + # Settings for the pool of renderers: + config.react.server_renderer_pool_size ||= 10 + config.react.server_renderer_timeout ||= 20 # seconds + config.react.server_renderer = React::ServerRendering::SprocketsRenderer + config.react.server_renderer_options = { + files: ["react.js", "components.js"], # files to load for prerendering + replay_console: true, # if true, console.* will be replayed client-side + } end ``` diff --git a/lib/react/rails/railtie.rb b/lib/react/rails/railtie.rb index 28c9bb170..855f80217 100644 --- a/lib/react/rails/railtie.rb +++ b/lib/react/rails/railtie.rb @@ -9,11 +9,11 @@ class Railtie < ::Rails::Railtie config.react.variant = (::Rails.env.production? ? :production : :development) config.react.addons = false config.react.jsx_transform_options = {} - # Server-side rendering - config.react.max_renderers = 10 - config.react.timeout = 20 #seconds - config.react.react_js = lambda {File.read(::Rails.application.assets.resolve('react.js'))} - config.react.component_filenames = ['components.js'] + # Server rendering: + config.react.server_renderer_pool_size = 10 + config.react.server_renderer_timeout = 20 # seconds + config.react.server_renderer = nil # defaults to SprocketsRenderer + config.react.server_renderer_options = {} # SprocketsRenderer provides defaults # Watch .jsx files for changes in dev, so we can reload the JS VMs with the new JS code. initializer "react_rails.add_watchable_files", group: :all do |app| @@ -49,10 +49,14 @@ class Railtie < ::Rails::Railtie "react-#{variant}", ].compact.join('-') - app.config.react.server_renderer_options ||= {} - app.config.react.server_renderer ||= React::ServerRendering::SprocketsRenderer - React::ServerRendering.renderer_options = app.config.react.server_renderer_options - React::ServerRendering.renderer = app.config.react.server_renderer + # The class isn't accessible in the configure block, so assign it here if it wasn't overridden: + app.config.react.server_renderer ||= React::ServerRendering::SprocketsRenderer + + React::ServerRendering.pool_size = app.config.react.server_renderer_pool_size + React::ServerRendering.pool_timeout = app.config.react.server_renderer_timeout + React::ServerRendering.renderer_options = app.config.react.server_renderer_options + React::ServerRendering.renderer = app.config.react.server_renderer + React::ServerRendering.reset_pool # Reload renderers in dev when files change ActionDispatch::Reloader.to_prepare { React::ServerRendering.reset_pool } diff --git a/lib/react/server_rendering.rb b/lib/react/server_rendering.rb index 27ead2c6b..050ad4d16 100644 --- a/lib/react/server_rendering.rb +++ b/lib/react/server_rendering.rb @@ -6,9 +6,6 @@ module ServerRendering mattr_accessor :renderer, :renderer_options, :pool_size, :pool_timeout - self.pool_size = 10 - self.pool_timeout = 20 - def self.reset_pool options = {size: pool_size, timeout: pool_timeout} @@pool = ConnectionPool.new(options) { create_renderer } From 61170a28d384ed0bdb0d6d10331810690115a808 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 30 Apr 2015 14:05:06 -0400 Subject: [PATCH 5/9] feat(ViewHelper) pass the prerender option to the renderer --- lib/react/rails/view_helper.rb | 5 +++-- test/{ => react/rails}/view_helper_test.rb | 0 2 files changed, 3 insertions(+), 2 deletions(-) rename test/{ => react/rails}/view_helper_test.rb (100%) diff --git a/lib/react/rails/view_helper.rb b/lib/react/rails/view_helper.rb index ad1e08637..992659230 100644 --- a/lib/react/rails/view_helper.rb +++ b/lib/react/rails/view_helper.rb @@ -7,8 +7,9 @@ module ViewHelper def react_component(name, props = {}, options = {}, &block) options = {:tag => options} if options.is_a?(Symbol) - if options[:prerender] - block = Proc.new{ concat React::ServerRendering.render(name, props) } + prerender_options = options[:prerender] + if prerender_options + block = Proc.new{ concat React::ServerRendering.render(name, props.merge(prerender: prerender_options)) } end html_options = options.reverse_merge(:data => {}) diff --git a/test/view_helper_test.rb b/test/react/rails/view_helper_test.rb similarity index 100% rename from test/view_helper_test.rb rename to test/react/rails/view_helper_test.rb From 298f23bb62d690add8dee75dfdb688a640ca870b Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 30 Apr 2015 14:08:56 -0400 Subject: [PATCH 6/9] feat(SprocketsRenderer) use prerender: :static --- lib/react/server_rendering/sprockets_renderer.rb | 8 +++++++- test/react/server_rendering/sprockets_renderer_test.rb | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/react/server_rendering/sprockets_renderer.rb b/lib/react/server_rendering/sprockets_renderer.rb index 275ca234c..e4f61fa45 100644 --- a/lib/react/server_rendering/sprockets_renderer.rb +++ b/lib/react/server_rendering/sprockets_renderer.rb @@ -10,13 +10,19 @@ def initialize(options={}) end def render(component_name, props) + # pass prerender: :static to use renderToStaticMarkup + react_render_method = "renderToString" + if props.is_a?(Hash) && props[:prerender] == :static + react_render_method = "renderToStaticMarkup" + end + if !props.is_a?(String) props = props.to_json end js_code = <<-JS (function () { - var result = React.renderToString(React.createElement(#{component_name}, #{props})); + var result = React.#{react_render_method}(React.createElement(#{component_name}, #{props})); #{@replay_console ? CONSOLE_REPLAY : ""} return result; })() diff --git a/test/react/server_rendering/sprockets_renderer_test.rb b/test/react/server_rendering/sprockets_renderer_test.rb index 1289c771e..a13e7a91d 100644 --- a/test/react/server_rendering/sprockets_renderer_test.rb +++ b/test/react/server_rendering/sprockets_renderer_test.rb @@ -16,6 +16,12 @@ class SprocketsRendererTest < ActiveSupport::TestCase assert_match(//, result) end + test '#render accepts prerender: :static' do + result = @renderer.render("Todo", {todo: "write more tests", prerender: :static}) + assert_match(/
  • write more tests<\/li>/, result) + assert_no_match(/data-react-checksum/, result) + end + test '#render replays console messages' do result = @renderer.render("TodoListWithConsoleLog", {todos: ["log some messages"]}) assert_match('console.log.apply(console, ["got initial state"])', result) From df6ceef8fcc7e8f27590e950209628ff2bfe4e25 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 30 Apr 2015 22:45:09 -0400 Subject: [PATCH 7/9] refactor(SprocketsRenderer) don't use SetupJavascript --- .../server_rendering/sprockets_renderer.rb | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/lib/react/server_rendering/sprockets_renderer.rb b/lib/react/server_rendering/sprockets_renderer.rb index e4f61fa45..94682bd43 100644 --- a/lib/react/server_rendering/sprockets_renderer.rb +++ b/lib/react/server_rendering/sprockets_renderer.rb @@ -5,15 +5,21 @@ def initialize(options={}) @replay_console = options.fetch(:replay_console, true) filenames = options.fetch(:files, ["react.js", "components.js"]) - js_code = SetupJavascript.new(filenames).code + js_code = GLOBAL_WRAPPER + CONSOLE_POLYFILL + + filenames.each do |filename| + js_code << ::Rails.application.assets[filename].to_s + end + @context = ExecJS.compile(js_code) end def render(component_name, props) # pass prerender: :static to use renderToStaticMarkup - react_render_method = "renderToString" if props.is_a?(Hash) && props[:prerender] == :static react_render_method = "renderToStaticMarkup" + else + react_render_method = "renderToString" end if !props.is_a?(String) @@ -33,23 +39,14 @@ def render(component_name, props) raise PrerenderError.new(component_name, props, err) end - class SetupJavascript - GLOBAL_WRAPPER = <<-JS - var global = global || this; - var self = self || this; - var window = window || this; - JS - - attr_reader :code - - def initialize(filenames) - @code = GLOBAL_WRAPPER + CONSOLE_POLYFILL - filenames.each do |filename| - @code << ::Rails.application.assets[filename].to_s - end - end - end + # Handle node.js & other RubyRacer contexts + GLOBAL_WRAPPER = <<-JS + var global = global || this; + var self = self || this; + var window = window || this; + JS + # Reimplement console methods for replaying on the client CONSOLE_POLYFILL = <<-JS var console = { history: [] }; ['error', 'log', 'info', 'warn'].forEach(function (fn) { @@ -59,6 +56,7 @@ def initialize(filenames) }); JS + # Replay message from console history CONSOLE_REPLAY = <<-JS (function (history) { if (history && history.length > 0) { From 486a8ea31d8641d980e5faced2a903094abcb00e Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 14 May 2015 09:04:08 -0700 Subject: [PATCH 8/9] Fix tests --- test/react/rails/view_helper_test.rb | 1 - .../sprockets_renderer_test.rb | 20 +++++++++---------- test/react/server_rendering_test.rb | 2 +- test/react_test.rb | 9 ++++++++- test/server_rendered_html_test.rb | 2 +- test/test_helper.rb | 1 + 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/test/react/rails/view_helper_test.rb b/test/react/rails/view_helper_test.rb index 2160a2304..7624b3c2f 100644 --- a/test/react/rails/view_helper_test.rb +++ b/test/react/rails/view_helper_test.rb @@ -129,7 +129,6 @@ class ViewHelperTest < ActionDispatch::IntegrationTest test 'react server rendering also gets mounted on client' do visit '/server/1' assert_match(/data-react-class=\"TodoList\"/, page.html) - assert_match(/data-react-checksum/, page.html) assert_match(/yep/, page.find("#status").text) end diff --git a/test/react/server_rendering/sprockets_renderer_test.rb b/test/react/server_rendering/sprockets_renderer_test.rb index a13e7a91d..0b39636b8 100644 --- a/test/react/server_rendering/sprockets_renderer_test.rb +++ b/test/react/server_rendering/sprockets_renderer_test.rb @@ -24,34 +24,34 @@ class SprocketsRendererTest < ActiveSupport::TestCase test '#render replays console messages' do result = @renderer.render("TodoListWithConsoleLog", {todos: ["log some messages"]}) - assert_match('console.log.apply(console, ["got initial state"])', result) - assert_match('console.warn.apply(console, ["mounted component"])', result) - assert_match('console.error.apply(console, ["rendered!","foo"])', result) + assert_match(/console.log.apply\(console, \["got initial state"\]\)/, result) + assert_match(/console.warn.apply\(console, \["mounted component"\]\)/, result) + assert_match(/console.error.apply\(console, \["rendered!","foo"\]\)/, result) end test '#render console messages can be disabled' do no_log_renderer = React::ServerRendering::SprocketsRenderer.new({replay_console: false}) result = no_log_renderer.render("TodoListWithConsoleLog", {todos: ["log some messages"]}) - assert_no_match('console.log.apply(console, ["got initial state"])', result) - assert_no_match('console.warn.apply(console, ["mounted component"])', result) - assert_no_match('console.error.apply(console, ["rendered!","foo"])', result) + assert_no_match(/console.log.apply\(console, \["got initial state"\]\)/, result) + assert_no_match(/console.warn.apply\(console, \["mounted component"\]\)/, result) + assert_no_match(/console.error.apply\(console, \["rendered!","foo"\]\)/, result) end test '#render errors include stack traces' do err = assert_raises React::ServerRendering::SprocketsRenderer::PrerenderError do @renderer.render("NonExistentComponent", {}) end - assert_match("ReferenceError", err.to_s) - assert_match("NonExistentComponent", err.to_s, "it names the component") + assert_match(/ReferenceError/, err.to_s) + assert_match(/NonExistentComponent/, err.to_s, "it names the component") assert_match(/\n/, err.to_s, "it includes the multi-line backtrace") end test '.new accepts any filenames' do limited_renderer = React::ServerRendering::SprocketsRenderer.new(files: ["react.js", "components/Todo.js"]) - assert_match("get a real job
  • ", limited_renderer.render("Todo", {todo: "get a real job"})) + assert_match(/get a real job<\/li>/, limited_renderer.render("Todo", {todo: "get a real job"})) err = assert_raises React::ServerRendering::SprocketsRenderer::PrerenderError do limited_renderer.render("TodoList", {todos: []}) end - assert_match("ReferenceError", err.to_s, "it doesnt load other files") + assert_match(/ReferenceError/, err.to_s, "it doesnt load other files") end end \ No newline at end of file diff --git a/test/react/server_rendering_test.rb b/test/react/server_rendering_test.rb index 4109daaad..a8a3b1fde 100644 --- a/test/react/server_rendering_test.rb +++ b/test/react/server_rendering_test.rb @@ -26,7 +26,7 @@ class ReactServerRenderingTest < ActiveSupport::TestCase end test '.create_renderer makes a renderer with initialization options' do - mock_renderer = MiniTest::Mock.new + mock_renderer = Minitest::Mock.new mock_renderer.expect(:new, :fake_renderer, [{mock: true}]) React::ServerRendering.renderer = mock_renderer React::ServerRendering.renderer_options = {mock: true} diff --git a/test/react_test.rb b/test/react_test.rb index 1d09c6072..dfd2ebedb 100644 --- a/test/react_test.rb +++ b/test/react_test.rb @@ -2,19 +2,26 @@ require 'fileutils' class ReactTest < ActionDispatch::IntegrationTest + setup do + Rails.application.assets.send(:expire_index!) + FileUtils.rm_r(CACHE_PATH) if CACHE_PATH.exist? + end + test 'asset pipeline should deliver drop-in react file replacement' do app_react_file_path = File.expand_path("../dummy/vendor/assets/javascripts/react.js", __FILE__) react_file_token = "'test_confirmation_token_react_content_non_production';\n"; File.write(app_react_file_path, react_file_token) + asset_pipeline_length = Rails.application.assets.find_asset('react').to_s.length get '/assets/react.js' File.unlink(app_react_file_path) FileUtils.rm_r CACHE_PATH if CACHE_PATH.exist? assert_response :success - assert_equal react_file_token, @response.body + assert_equal react_file_token.length, asset_pipeline_length, "The asset pipeline serves the drop-in file" + assert_equal react_file_token.length, @response.body.length, "The asset route serves the drop-in file" end test 'precompiling assets works' do diff --git a/test/server_rendered_html_test.rb b/test/server_rendered_html_test.rb index 8b9ceba83..09fb7eb5c 100644 --- a/test/server_rendered_html_test.rb +++ b/test/server_rendered_html_test.rb @@ -46,7 +46,7 @@ def wait_to_ensure_asset_pipeline_detects_changes # Make sure they're not when we don't ask for them get '/server/console_example_suppressed' - assert_match('Console Logged', response.body) + assert_match(/Console Logged/, response.body) assert_no_match(/console.log/, response.body) assert_no_match(/console.warn/, response.body) assert_no_match(/console.error/, response.body) diff --git a/test/test_helper.rb b/test/test_helper.rb index a65a1b5b7..72b61e4a3 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -5,6 +5,7 @@ require "rails/test_help" require "rails/generators" require "pathname" +require 'minitest/mock' CACHE_PATH = Pathname.new File.expand_path("../dummy/tmp/cache", __FILE__) From 981e3d9c40fa5f67ad715f94734dc522051225da Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Thu, 14 May 2015 12:13:20 -0700 Subject: [PATCH 9/9] feat(renderer) fix asset test --- test/react_test.rb | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/test/react_test.rb b/test/react_test.rb index dfd2ebedb..a59ba0762 100644 --- a/test/react_test.rb +++ b/test/react_test.rb @@ -1,26 +1,34 @@ require 'test_helper' -require 'fileutils' - class ReactTest < ActionDispatch::IntegrationTest setup do - Rails.application.assets.send(:expire_index!) + FileUtils.rm_r(CACHE_PATH) if CACHE_PATH.exist? + + end + + teardown do FileUtils.rm_r(CACHE_PATH) if CACHE_PATH.exist? end test 'asset pipeline should deliver drop-in react file replacement' do app_react_file_path = File.expand_path("../dummy/vendor/assets/javascripts/react.js", __FILE__) - - react_file_token = "'test_confirmation_token_react_content_non_production';\n"; + react_file_token = "'test_confirmation_token_react_content_non_production';\n" File.write(app_react_file_path, react_file_token) - asset_pipeline_length = Rails.application.assets.find_asset('react').to_s.length + react_asset = Rails.application.assets['react.js'] + + # Sprockets 2 doesn't expire this asset correctly, + # so override `fresh?` to mark it as expired. + def react_asset.fresh?(env); false; end + + react_asset = Rails.application.assets['react.js'] + get '/assets/react.js' File.unlink(app_react_file_path) - FileUtils.rm_r CACHE_PATH if CACHE_PATH.exist? + FileUtils.rm_r(CACHE_PATH) if CACHE_PATH.exist? assert_response :success - assert_equal react_file_token.length, asset_pipeline_length, "The asset pipeline serves the drop-in file" + assert_equal react_file_token.length, react_asset.to_s.length, "The asset pipeline serves the drop-in file" assert_equal react_file_token.length, @response.body.length, "The asset route serves the drop-in file" end