diff --git a/lib/react/rails.rb b/lib/react/rails.rb index 76496240..788b610c 100644 --- a/lib/react/rails.rb +++ b/lib/react/rails.rb @@ -1,6 +1,8 @@ require 'react/rails/asset_variant' require 'react/rails/engine' require 'react/rails/railtie' +require 'react/rails/render_middleware' require 'react/rails/version' +require 'react/rails/component_mount' require 'react/rails/view_helper' require 'react/rails/controller_renderer' diff --git a/lib/react/rails/component_mount.rb b/lib/react/rails/component_mount.rb new file mode 100644 index 00000000..506d3391 --- /dev/null +++ b/lib/react/rails/component_mount.rb @@ -0,0 +1,46 @@ +module React + module Rails + # This is the default view helper implementation. + # It just inserts HTML into the DOM (see {#react_component}). + # + # You can extend this class or provide your own implementation + # by assigning it to `config.react.view_helper_implementation`. + class ComponentMount + include ActionView::Helpers::TagHelper + include ActionView::Helpers::TextHelper + attr_accessor :output_buffer + + # RenderMiddleware calls these hooks + # You can use them in custom helper implementations + def setup(env) + end + + def teardown(env) + end + + # 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, props = {}, options = {}, &block) + options = {:tag => options} if options.is_a?(Symbol) + + prerender_options = options[:prerender] + if prerender_options + block = Proc.new{ concat React::ServerRendering.render(name, props, prerender_options) } + end + + html_options = options.reverse_merge(:data => {}) + html_options[:data].tap do |data| + data[:react_class] = name + 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/rails/controller_renderer.rb b/lib/react/rails/controller_renderer.rb index 65d0c245..b78a87e5 100644 --- a/lib/react/rails/controller_renderer.rb +++ b/lib/react/rails/controller_renderer.rb @@ -5,8 +5,9 @@ class React::Rails::ControllerRenderer attr_accessor :output_buffer - def self.call(*args, &block) - new.call(*args, &block) + attr_reader :request + def initialize(options={}) + @request = options[:request] end def call(name, options, &block) diff --git a/lib/react/rails/railtie.rb b/lib/react/rails/railtie.rb index 47ffcfed..0bd0fe23 100644 --- a/lib/react/rails/railtie.rb +++ b/lib/react/rails/railtie.rb @@ -4,7 +4,6 @@ module React module Rails class Railtie < ::Rails::Railtie config.react = ActiveSupport::OrderedOptions.new - # Sensible defaults. Can be overridden in application.rb config.react.variant = (::Rails.env.production? ? :production : :development) config.react.addons = false @@ -15,6 +14,8 @@ class Railtie < ::Rails::Railtie config.react.server_renderer_timeout = 20 # seconds config.react.server_renderer = nil # defaults to SprocketsRenderer config.react.server_renderer_options = {} # SprocketsRenderer provides defaults + # View helper implementation: + config.react.view_helper_implementation = nil # Defaults to ComponentMount # 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| @@ -23,10 +24,13 @@ class Railtie < ::Rails::Railtie # Include the react-rails view helper lazily initializer "react_rails.setup_view_helpers", group: :all do |app| + app.config.middleware.use(::React::Rails::RenderMiddleware) app.config.react.jsx_transformer_class ||= React::JSX::DEFAULT_TRANSFORMER React::JSX.transformer_class = app.config.react.jsx_transformer_class React::JSX.transform_options = app.config.react.jsx_transform_options + app.config.react.view_helper_implementation ||= React::Rails::ComponentMount + React::Rails::ViewHelper.helper_implementation_class = app.config.react.view_helper_implementation ActiveSupport.on_load(:action_view) do include ::React::Rails::ViewHelper end @@ -34,7 +38,8 @@ class Railtie < ::Rails::Railtie initializer "react_rails.add_component_renderer", group: :all do |app| ActionController::Renderers.add :component do |component_name, options| - html = ::React::Rails::ControllerRenderer.call(component_name, options) + renderer = ::React::Rails::ControllerRenderer.new(request: request) + html = renderer.call(component_name, options) render_options = options.merge(inline: html) render(render_options) end diff --git a/lib/react/rails/render_middleware.rb b/lib/react/rails/render_middleware.rb new file mode 100644 index 00000000..e038cc3b --- /dev/null +++ b/lib/react/rails/render_middleware.rb @@ -0,0 +1,19 @@ +module React + module Rails + class RenderMiddleware + HELPER_IMPLEMENTATION_KEY = "react_rails.view_helper_implementation" + def initialize(app) + @app = app + end + + def call(env) + new_helper = React::Rails::ViewHelper.helper_implementation_class.new + new_helper.setup(env) + env[HELPER_IMPLEMENTATION_KEY] = new_helper + response = @app.call(env) + new_helper.teardown(env) + response + end + end + end +end diff --git a/lib/react/rails/view_helper.rb b/lib/react/rails/view_helper.rb index 0a6041b1..eeca5dc4 100644 --- a/lib/react/rails/view_helper.rb +++ b/lib/react/rails/view_helper.rb @@ -1,28 +1,18 @@ 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, props = {}, options = {}, &block) - options = {:tag => options} if options.is_a?(Symbol) + # This class will be used for inserting tags into HTML. + # It should implement: + # - #setup(env) + # - #teardown(env) + # - #react_component(name, props, options &block) + # The default is {React::Rails::ComponentMount} + mattr_accessor :helper_implementation_class - prerender_options = options[:prerender] - if prerender_options - block = Proc.new{ concat React::ServerRendering.render(name, props, prerender_options) } - end - - html_options = options.reverse_merge(:data => {}) - html_options[:data].tap do |data| - data[:react_class] = name - 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) + def react_component(*args, &block) + impl_key = React::Rails::RenderMiddleware::HELPER_IMPLEMENTATION_KEY + helper_obj = request.env[impl_key] + helper_obj.react_component(*args, &block) end end end diff --git a/test/react/rails/component_mount_test.rb b/test/react/rails/component_mount_test.rb new file mode 100644 index 00000000..d8d99ffa --- /dev/null +++ b/test/react/rails/component_mount_test.rb @@ -0,0 +1,49 @@ +require 'test_helper' + +class ComponentMountTest < ActionDispatch::IntegrationTest + setup do + @helper = React::Rails::ComponentMount.new + end + + test '#react_component accepts React props' do + html = @helper.react_component('Foo', {bar: 'value'}) + expected_props = %w(data-react-class="Foo" data-react-props="{"bar":"value"}") + expected_props.each do |segment| + assert html.include?(segment) + end + end + + test '#react_component accepts jbuilder-based strings as properties' do + jbuilder_json = Jbuilder.new do |json| + json.bar 'value' + end.target! + + html = @helper.react_component('Foo', jbuilder_json) + expected_props = %w(data-react-class="Foo" data-react-props="{"bar":"value"}") + expected_props.each do |segment| + assert html.include?(segment), "expected #{html} to include #{segment}" + end + end + + test '#react_component accepts string props with prerender: true' do + html = @helper.react_component('Todo', {todo: 'render on the server'}.to_json, prerender: true) + assert(html.include?('data-react-class="Todo"'), "it includes attrs for UJS") + assert(html.include?('>render on the server'), "it includes rendered HTML") + assert(html.include?('data-reactid'), "it includes React properties") + end + + test '#react_component passes :static to SprocketsRenderer' do + html = @helper.react_component('Todo', {todo: 'render on the server'}.to_json, prerender: :static) + assert(html.include?('>render on the server'), "it includes rendered HTML") + assert(!html.include?('data-reactid'), "it DOESNT INCLUDE React properties") + end + + test '#react_component accepts HTML options and HTML tag' do + assert @helper.react_component('Foo', {}, :span).match(/<\/span>/) + + html = @helper.react_component('Foo', {}, {class: 'test', tag: :span, data: {foo: 1}}) + assert html.match(/<\/span>/) + assert html.include?('class="test"') + assert html.include?('data-foo="1"') + end +end diff --git a/test/react/rails/render_middleware_test.rb b/test/react/rails/render_middleware_test.rb new file mode 100644 index 00000000..35848c6f --- /dev/null +++ b/test/react/rails/render_middleware_test.rb @@ -0,0 +1,56 @@ +require 'test_helper' + +# This helper implementation just counts the number of +# calls to `react_component` +class DummyHelperImplementation + attr_reader :events + def initialize + @events = [] + end + + def setup(env) + @events << :setup + end + + def teardown(env) + @events << :teardown + end + + def react_component(*args) + @events << :react_component + end +end + +class RenderMiddlewareTest < ActionDispatch::IntegrationTest + impl_key = React::Rails::RenderMiddleware::HELPER_IMPLEMENTATION_KEY + + def setup + @previous_helper_implementation = React::Rails::ViewHelper.helper_implementation_class + React::Rails::ViewHelper.helper_implementation_class = DummyHelperImplementation + end + + def teardown + React::Rails::ViewHelper.helper_implementation_class = @previous_helper_implementation + end + + test "it creates a helper object and puts it in the request env" do + get '/pages/1' + helper_obj = request.env[impl_key] + assert(helper_obj.is_a?(DummyHelperImplementation), "It uses the view helper implementation class") + end + + test "it calls setup and teardown methods" do + get '/pages/1' + helper_obj = request.env[impl_key] + lifecycle_steps = [:setup, :react_component, :teardown] + assert_equal(lifecycle_steps, helper_obj.events) + end + + test "there's a new helper object for every request" do + get '/pages/1' + first_helper = request.env[impl_key] + get '/pages/1' + second_helper = request.env[impl_key] + assert(first_helper != second_helper, "The helper for the second request is brand new") + end +end diff --git a/test/react/rails/view_helper_test.rb b/test/react/rails/view_helper_test.rb index 22b43123..45236fe2 100644 --- a/test/react/rails/view_helper_test.rb +++ b/test/react/rails/view_helper_test.rb @@ -18,50 +18,9 @@ class ViewHelperTest < ActionDispatch::IntegrationTest include Capybara::DSL setup do - @helper = ActionView::Base.new.extend(React::Rails::ViewHelper) Capybara.current_driver = Capybara.javascript_driver end - test 'react_component accepts React props' do - html = @helper.react_component('Foo', {bar: 'value'}) - %w(data-react-class="Foo" data-react-props="{"bar":"value"}").each do |segment| - assert html.include?(segment) - end - end - - test 'react_component accepts jbuilder-based strings as properties' do - jbuilder_json = Jbuilder.new do |json| - json.bar 'value' - end.target! - - html = @helper.react_component('Foo', jbuilder_json) - %w(data-react-class="Foo" data-react-props="{"bar":"value"}").each do |segment| - assert html.include?(segment), "expected #{html} to include #{segment}" - end - end - - test 'react_component accepts string props with prerender: true' do - html = @helper.react_component('Todo', {todo: 'render on the server'}.to_json, prerender: true) - assert(html.include?('data-react-class="Todo"'), "it includes attrs for UJS") - assert(html.include?('>render on the server'), "it includes rendered HTML") - assert(html.include?('data-reactid'), "it includes React properties") - end - - test 'react_component passes :static to SprocketsRenderer' do - html = @helper.react_component('Todo', {todo: 'render on the server'}.to_json, prerender: :static) - assert(html.include?('>render on the server'), "it includes rendered HTML") - assert(!html.include?('data-reactid'), "it DOESNT INCLUDE React properties") - end - - test 'react_component accepts HTML options and HTML tag' do - assert @helper.react_component('Foo', {}, :span).match(/<\/span>/) - - html = @helper.react_component('Foo', {}, {:class => 'test', :tag => :span, :data => {:foo => 1}}) - assert html.match(/<\/span>/) - assert html.include?('class="test"') - assert html.include?('data-foo="1"') - end - test 'ujs object present on the global React object and has our methods' do visit '/pages/1' assert page.has_content?('Hello Bob')