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/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.rb b/lib/react-rails.rb index 9e75b37ab..fdb8abf93 100644 --- a/lib/react-rails.rb +++ b/lib/react-rails.rb @@ -1,5 +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..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,24 +49,17 @@ 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(";") - } + # 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 - 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 + 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 - # Reload the JS VMs in dev when files change - ActionDispatch::Reloader.to_prepare(&do_setup) + 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..992659230 100644 --- a/lib/react/rails/view_helper.rb +++ b/lib/react/rails/view_helper.rb @@ -1,28 +1,29 @@ 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] + + 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 => {}) 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/lib/react/server_rendering.rb b/lib/react/server_rendering.rb new file mode 100644 index 000000000..050ad4d16 --- /dev/null +++ b/lib/react/server_rendering.rb @@ -0,0 +1,24 @@ +require 'connection_pool' +require 'react/server_rendering/sprockets_renderer' + +module React + module ServerRendering + mattr_accessor :renderer, :renderer_options, + :pool_size, :pool_timeout + + 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..94682bd43 --- /dev/null +++ b/lib/react/server_rendering/sprockets_renderer.rb @@ -0,0 +1,81 @@ +module React + module ServerRendering + class SprocketsRenderer + def initialize(options={}) + @replay_console = options.fetch(:replay_console, true) + + filenames = options.fetch(:files, ["react.js", "components.js"]) + 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 + if props.is_a?(Hash) && props[:prerender] == :static + react_render_method = "renderToStaticMarkup" + else + react_render_method = "renderToString" + end + + if !props.is_a?(String) + props = props.to_json + end + + js_code = <<-JS + (function () { + var result = React.#{react_render_method}(React.createElement(#{component_name}, #{props})); + #{@replay_console ? CONSOLE_REPLAY : ""} + return result; + })() + JS + + @context.eval(js_code).html_safe + rescue ExecJS::ProgramError => err + raise PrerenderError.new(component_name, props, err) + 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) { + console[fn] = function () { + console.history.push({level: fn, arguments: Array.prototype.slice.call(arguments)}); + }; + }); + JS + + # Replay message from console history + 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 + + 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 + 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/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/view_helper_test.rb b/test/react/rails/view_helper_test.rb similarity index 98% rename from test/view_helper_test.rb rename to test/react/rails/view_helper_test.rb index 2160a2304..7624b3c2f 100644 --- a/test/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 new file mode 100644 index 000000000..0b39636b8 --- /dev/null +++ b/test/react/server_rendering/sprockets_renderer_test.rb @@ -0,0 +1,57 @@ +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"}) + 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 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) + 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<\/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") + 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..a8a3b1fde --- /dev/null +++ b/test/react/server_rendering_test.rb @@ -0,0 +1,55 @@ +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 + @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}]) + 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 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 diff --git a/test/react_test.rb b/test/react_test.rb index 1d09c6072..a59ba0762 100644 --- a/test/react_test.rb +++ b/test/react_test.rb @@ -1,20 +1,35 @@ require 'test_helper' -require 'fileutils' - class ReactTest < ActionDispatch::IntegrationTest + setup do + 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) + 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, @response.body + 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 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__)