From 3c1152a45086c04a2a0af92372a45f722b1863e8 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 31 Aug 2015 11:34:49 -0700 Subject: [PATCH 1/3] refactor(ViewHelper) move HTML tag to ComponentMount --- lib/react/rails.rb | 1 + lib/react/rails/component_mount.rb | 33 ++++++++++++++++ lib/react/rails/railtie.rb | 4 ++ lib/react/rails/view_helper.rb | 27 +++---------- test/react/rails/component_mount_test.rb | 49 ++++++++++++++++++++++++ test/react/rails/view_helper_test.rb | 41 -------------------- 6 files changed, 93 insertions(+), 62 deletions(-) create mode 100644 lib/react/rails/component_mount.rb create mode 100644 test/react/rails/component_mount_test.rb diff --git a/lib/react/rails.rb b/lib/react/rails.rb index 76496240..aa25547d 100644 --- a/lib/react/rails.rb +++ b/lib/react/rails.rb @@ -2,5 +2,6 @@ require 'react/rails/engine' require 'react/rails/railtie' 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..182d3344 --- /dev/null +++ b/lib/react/rails/component_mount.rb @@ -0,0 +1,33 @@ +module React + module Rails + class ComponentMount + include ActionView::Helpers::TagHelper + include ActionView::Helpers::TextHelper + attr_accessor :output_buffer + + # 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/railtie.rb b/lib/react/rails/railtie.rb index 47ffcfed..6493ff3a 100644 --- a/lib/react/rails/railtie.rb +++ b/lib/react/rails/railtie.rb @@ -15,6 +15,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| @@ -27,6 +29,8 @@ class Railtie < ::Rails::Railtie 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 diff --git a/lib/react/rails/view_helper.rb b/lib/react/rails/view_helper.rb index 0a6041b1..49e203d7 100644 --- a/lib/react/rails/view_helper.rb +++ b/lib/react/rails/view_helper.rb @@ -1,28 +1,13 @@ 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 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) + self.helper_implementation_class.new.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/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') From 2a862103c6a8fa3e811ada84f44818d5be8e87cd Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 31 Aug 2015 12:00:44 -0700 Subject: [PATCH 2/3] feat(RenderMiddleware) use middleware for view helper lifecycle --- lib/react/rails.rb | 1 + lib/react/rails/railtie.rb | 2 +- lib/react/rails/render_middleware.rb | 16 +++++++++ lib/react/rails/view_helper.rb | 4 ++- test/react/rails/render_middleware_test.rb | 42 ++++++++++++++++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 lib/react/rails/render_middleware.rb create mode 100644 test/react/rails/render_middleware_test.rb diff --git a/lib/react/rails.rb b/lib/react/rails.rb index aa25547d..788b610c 100644 --- a/lib/react/rails.rb +++ b/lib/react/rails.rb @@ -1,6 +1,7 @@ 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' diff --git a/lib/react/rails/railtie.rb b/lib/react/rails/railtie.rb index 6493ff3a..c6495799 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 @@ -25,6 +24,7 @@ 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 diff --git a/lib/react/rails/render_middleware.rb b/lib/react/rails/render_middleware.rb new file mode 100644 index 00000000..d3c13b8d --- /dev/null +++ b/lib/react/rails/render_middleware.rb @@ -0,0 +1,16 @@ +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 + env[HELPER_IMPLEMENTATION_KEY] = new_helper + @app.call(env) + end + end + end +end diff --git a/lib/react/rails/view_helper.rb b/lib/react/rails/view_helper.rb index 49e203d7..9e1db49e 100644 --- a/lib/react/rails/view_helper.rb +++ b/lib/react/rails/view_helper.rb @@ -7,7 +7,9 @@ module ViewHelper mattr_accessor :helper_implementation_class def react_component(*args, &block) - self.helper_implementation_class.new.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/render_middleware_test.rb b/test/react/rails/render_middleware_test.rb new file mode 100644 index 00000000..a0d20808 --- /dev/null +++ b/test/react/rails/render_middleware_test.rb @@ -0,0 +1,42 @@ +require 'test_helper' + +# This helper implementation just counts the number of +# calls to `react_component` +class DummyHelperImplementation + attr_reader :counter + def initialize + @counter = 0 + end + + def react_component(*args) + @counter += 1 + 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") + assert_equal(1, helper_obj.counter, "It uses that object during rendering") + 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 From 0c770423a776875b7d5c5b0fce7896c04cf67571 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 31 Aug 2015 14:04:57 -0700 Subject: [PATCH 3/3] feat(ViewHelper) add lifecycle hooks --- lib/react/rails/component_mount.rb | 13 +++++++++++++ lib/react/rails/controller_renderer.rb | 5 +++-- lib/react/rails/railtie.rb | 3 ++- lib/react/rails/render_middleware.rb | 5 ++++- lib/react/rails/view_helper.rb | 5 ++++- test/react/rails/render_middleware_test.rb | 22 ++++++++++++++++++---- 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/lib/react/rails/component_mount.rb b/lib/react/rails/component_mount.rb index 182d3344..506d3391 100644 --- a/lib/react/rails/component_mount.rb +++ b/lib/react/rails/component_mount.rb @@ -1,10 +1,23 @@ 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. 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 c6495799..0bd0fe23 100644 --- a/lib/react/rails/railtie.rb +++ b/lib/react/rails/railtie.rb @@ -38,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 index d3c13b8d..e038cc3b 100644 --- a/lib/react/rails/render_middleware.rb +++ b/lib/react/rails/render_middleware.rb @@ -8,8 +8,11 @@ def initialize(app) def call(env) new_helper = React::Rails::ViewHelper.helper_implementation_class.new + new_helper.setup(env) env[HELPER_IMPLEMENTATION_KEY] = new_helper - @app.call(env) + response = @app.call(env) + new_helper.teardown(env) + response end end end diff --git a/lib/react/rails/view_helper.rb b/lib/react/rails/view_helper.rb index 9e1db49e..eeca5dc4 100644 --- a/lib/react/rails/view_helper.rb +++ b/lib/react/rails/view_helper.rb @@ -2,7 +2,10 @@ module React module Rails module ViewHelper # This class will be used for inserting tags into HTML. - # It should implement react_component(name, props, options &block) + # It should implement: + # - #setup(env) + # - #teardown(env) + # - #react_component(name, props, options &block) # The default is {React::Rails::ComponentMount} mattr_accessor :helper_implementation_class diff --git a/test/react/rails/render_middleware_test.rb b/test/react/rails/render_middleware_test.rb index a0d20808..35848c6f 100644 --- a/test/react/rails/render_middleware_test.rb +++ b/test/react/rails/render_middleware_test.rb @@ -3,13 +3,21 @@ # This helper implementation just counts the number of # calls to `react_component` class DummyHelperImplementation - attr_reader :counter + attr_reader :events def initialize - @counter = 0 + @events = [] + end + + def setup(env) + @events << :setup + end + + def teardown(env) + @events << :teardown end def react_component(*args) - @counter += 1 + @events << :react_component end end @@ -29,7 +37,13 @@ def teardown get '/pages/1' helper_obj = request.env[impl_key] assert(helper_obj.is_a?(DummyHelperImplementation), "It uses the view helper implementation class") - assert_equal(1, helper_obj.counter, "It uses that object during rendering") + 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