# frozen_string_literal: true # rubocop:disable Metrics/ModuleLength # NOTE: # For any heredoc JS: # 1. The white spacing in this file matters! # 2. Keep all #{some_var} fully to the left so that all indentation is done evenly in that var require "react_on_rails/prerender_error" require "addressable/uri" require "react_on_rails/utils" require "react_on_rails/json_output" module ReactOnRailsHelper include ReactOnRails::Utils::Required COMPONENT_HTML_KEY = "componentHtml" # The env_javascript_include_tag and env_stylesheet_link_tag support the usage of a webpack # dev server for providing the JS and CSS assets during development mode. See # https://github.com/shakacode/react-webpack-rails-tutorial/ for a working example. # # The key options are `static` and `hot` which specify what you want for static vs. hot. Both of # these params are optional, and support either a single value, or an array. # # static vs. hot is picked based on whether # ENV["REACT_ON_RAILS_ENV"] == "HOT" # # <%= env_stylesheet_link_tag(static: 'application_static', # hot: 'application_non_webpack', # media: 'all', # 'data-turbolinks-track' => "reload") %> # # # # <%= env_javascript_include_tag(hot: ['http://localhost:3500/vendor-bundle.js', # 'http://localhost:3500/app-bundle.js']) %> # # # <%= env_javascript_include_tag(static: 'application_static', # hot: 'application_non_webpack', # 'data-turbolinks-track' => "reload") %> # # NOTE: for Turbolinks 2.x, use 'data-turbolinks-track' => true # See application.html.erb for usage example # https://github.com/shakacode/react-webpack-rails-tutorial/blob/master/app%2Fviews%2Flayouts%2Fapplication.html.erb def env_javascript_include_tag(args = {}) send_tag_method(:javascript_include_tag, args) end # Helper to set CSS assets depending on if we want static or "hot", which means from the # Webpack dev server. # # In this example, application_non_webpack is simply a CSS asset pipeline file which includes # styles not placed in the webpack build. # # We don't need styles from the webpack build, as those will come via the JavaScript include # tags. # # The key options are `static` and `hot` which specify what you want for static vs. hot. Both of # these params are optional, and support either a single value, or an array. # # <%= env_stylesheet_link_tag(static: 'application_static', # hot: 'application_non_webpack', # media: 'all', # 'data-turbolinks-track' => true) %> # def env_stylesheet_link_tag(args = {}) send_tag_method(:stylesheet_link_tag, args) end # react_component_name: can be a React component, created using a ES6 class, or # React.createClass, or a # `generator function` that returns a React component # using ES6 # let MyReactComponentApp = (props, railsContext) => ; # or using ES5 # var MyReactComponentApp = function(props, railsContext) { return ; } # Exposing the react_component_name is necessary to both a plain ReactComponent as well as # a generator: # See README.md for how to "register" your react components. # See spec/dummy/client/app/startup/serverRegistration.jsx and # spec/dummy/client/app/startup/ClientRegistration.jsx for examples of this # # options: # props: Ruby Hash or JSON string which contains the properties to pass to the react object. Do # not pass any props if you are separately initializing the store by the `redux_store` helper. # prerender: set to false when debugging! # id: You can optionally set the id, or else a unique one is automatically generated. # html_options: You can set other html attributes that will go on this component # trace: set to true to print additional debugging information in the browser # default is true for development, off otherwise # replay_console: Default is true. False will disable echoing server rendering # logs to browser. While this can make troubleshooting server rendering difficult, # so long as you have the default configuration of logging_on_server set to # true, you'll still see the errors on the server. # raise_on_prerender_error: Default to false. True will raise exception on server # if the JS code throws # Any other options are passed to the content tag, including the id. def react_component(component_name, raw_options = {}) # Create the JavaScript and HTML to allow either client or server rendering of the # react_component. # # Create the JavaScript setup of the global to initialize the client rendering # (re-hydrate the data). This enables react rendered on the client to see that the # server has already rendered the HTML. options = ReactOnRails::ReactComponent::Options.new(name: component_name, options: raw_options) # Setup the page_loaded_js, which is the same regardless of prerendering or not! # The reason is that React is smart about not doing extra work if the server rendering did its job. component_specification_tag = content_tag(:script, json_safe_and_pretty(options.props).html_safe, type: "application/json", class: "js-react-on-rails-component", "data-component-name" => options.name, "data-trace" => (options.trace ? true : nil), "data-dom-id" => options.dom_id) # Create the HTML rendering part result = server_rendered_react_component_html(options.props, options.name, options.dom_id, prerender: options.prerender, trace: options.trace, raise_on_prerender_error: options.raise_on_prerender_error) server_rendered_html = result["html"] console_script = result["consoleReplayScript"] if server_rendered_html.is_a?(String) build_react_component_result_for_server_rendered_string( server_rendered_html: server_rendered_html, component_specification_tag: component_specification_tag, console_script: console_script, options: options ) elsif server_rendered_html.is_a?(Hash) build_react_component_result_for_server_rendered_hash( server_rendered_html: server_rendered_html, component_specification_tag: component_specification_tag, console_script: console_script, options: options ) else raise "server_rendered_html expected to be a String or a Hash." end end # Separate initialization of store from react_component allows multiple react_component calls to # use the same Redux store. # # store_name: name of the store, corresponding to your call to ReactOnRails.registerStores in your # JavaScript code. # props: Ruby Hash or JSON string which contains the properties to pass to the redux store. # Options # defer: false -- pass as true if you wish to render this below your component. def redux_store(store_name, props: {}, defer: false) redux_store_data = { store_name: store_name, props: props } if defer @registered_stores_defer_render ||= [] @registered_stores_defer_render << redux_store_data "YOU SHOULD NOT SEE THIS ON YOUR VIEW -- Uses as a code block, like <% redux_store %> "\ "and not <%= redux store %>" else @registered_stores ||= [] @registered_stores << redux_store_data result = render_redux_store_data(redux_store_data) prepend_render_rails_context(result) end end # Place this view helper (no parameters) at the end of your shared layout. This tell # ReactOnRails where to client render the redux store hydration data. Since we're going # to be setting up the stores in the controllers, we need to know where on the view to put the # client side rendering of this hydration data, which is a hidden div with a matching class # that contains a data props. def redux_store_hydration_data return if @registered_stores_defer_render.blank? @registered_stores_defer_render.reduce("".dup) do |accum, redux_store_data| accum << render_redux_store_data(redux_store_data) end.html_safe end def sanitized_props_string(props) ReactOnRails::JsonOutput.escape(props.is_a?(String) ? props : props.to_json) end # Helper method to take javascript expression and returns the output from evaluating it. # If you have more than one line that needs to be executed, wrap it in an IIFE. # JS exceptions are caught and console messages are handled properly. def server_render_js(js_expression, options = {}) wrapper_js = <<-JS.strip_heredoc (function() { var htmlResult = ''; var consoleReplayScript = ''; var hasErrors = false; try { htmlResult = (function() { return #{js_expression}; })(); } catch(e) { htmlResult = ReactOnRails.handleError({e: e, name: null, jsCode: '#{escape_javascript(js_expression)}', serverSide: true}); hasErrors = true; } consoleReplayScript = ReactOnRails.buildConsoleReplay(); return JSON.stringify({ html: htmlResult, consoleReplayScript: consoleReplayScript, hasErrors: hasErrors }); })() JS result = ReactOnRails::ServerRenderingPool.server_render_js_with_console_logging(wrapper_js) # IMPORTANT: To ensure that Rails doesn't auto-escape HTML tags, use the 'raw' method. html = result["html"] console_log_script = result["consoleLogScript"] raw("#{html}#{replay_console_option(options[:replay_console_option]) ? console_log_script : ''}") rescue ExecJS::ProgramError => err raise ReactOnRails::PrerenderError, component_name: "N/A (server_render_js called)", err: err, js_code: wrapper_js # rubocop:enable Style/RaiseArgs end def json_safe_and_pretty(hash_or_string) return "{}" if hash_or_string.nil? unless hash_or_string.class.in?([Hash, String]) raise "#{__method__} only accepts String or Hash as argument "\ "(#{hash_or_string.class} given)." end json_value = hash_or_string.is_a?(String) ? hash_or_string : hash_or_string.to_json ReactOnRails::JsonOutput.escape(json_value) end private def build_react_component_result_for_server_rendered_string( server_rendered_html: required("server_rendered_html"), component_specification_tag: required("component_specification_tag"), console_script: required("console_script"), options: required("options") ) content_tag_options = options.html_options content_tag_options[:id] = options.dom_id rendered_output = content_tag(:div, server_rendered_html.html_safe, content_tag_options) result_console_script = options.replay_console ? console_script : "" result = compose_react_component_html_with_spec_and_console( component_specification_tag, rendered_output, result_console_script ) prepend_render_rails_context(result) end def build_react_component_result_for_server_rendered_hash( server_rendered_html: required("server_rendered_html"), component_specification_tag: required("component_specification_tag"), console_script: required("console_script"), options: required("options") ) content_tag_options = options.html_options content_tag_options[:id] = options.dom_id unless server_rendered_html[COMPONENT_HTML_KEY] raise "server_rendered_html hash expected to contain \"#{COMPONENT_HTML_KEY}\" key." end rendered_output = content_tag(:div, server_rendered_html[COMPONENT_HTML_KEY].html_safe, content_tag_options) result_console_script = options.replay_console ? console_script : "" result = compose_react_component_html_with_spec_and_console( component_specification_tag, rendered_output, result_console_script ) # Other HTML strings need to be marked as html_safe too: server_rendered_hash_except_component = server_rendered_html.except(COMPONENT_HTML_KEY) server_rendered_hash_except_component.each do |key, html_string| server_rendered_hash_except_component[key] = html_string.html_safe end result_with_rails_context = prepend_render_rails_context(result) { COMPONENT_HTML_KEY => result_with_rails_context }.merge( server_rendered_hash_except_component ) end def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script) # IMPORTANT: Ensure that we mark string as html_safe to avoid escaping. # rubocop:disable Layout/IndentHeredoc <<-HTML.html_safe #{component_specification_tag} #{rendered_output} #{console_script} HTML # rubocop:enable Layout/IndentHeredoc end # prepend the rails_context if not yet applied def prepend_render_rails_context(render_value) return render_value if @rendered_rails_context data = rails_context(server_side: false) @rendered_rails_context = true rails_context_content = content_tag(:script, json_safe_and_pretty(data).html_safe, type: "application/json", id: "js-react-on-rails-context") "#{rails_context_content}\n#{render_value}".html_safe end def render_redux_store_data(redux_store_data) result = content_tag(:script, json_safe_and_pretty(redux_store_data[:props]).html_safe, type: "application/json", "data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe) prepend_render_rails_context(result) end def props_string(props) props.is_a?(String) ? props : props.to_json end # Returns Array [0]: html, [1]: script to console log # NOTE, these are NOT html_safe! def server_rendered_react_component_html( props, react_component_name, dom_id, prerender: required("prerender"), trace: required("trace"), raise_on_prerender_error: required("raise_on_prerender_error") ) return { "html" => "", "consoleReplayScript" => "" } unless prerender # On server `location` option is added (`location = request.fullpath`) # React Router needs this to match the current route # Make sure that we use up-to-date bundle file used for server rendering, which is defined # by config file value for config.server_bundle_js_file ReactOnRails::ServerRenderingPool.reset_pool_if_server_bundle_was_modified # Since this code is not inserted on a web page, we don't need to escape props # # However, as JSON (returned from `props_string(props)`) isn't JavaScript, # but we want treat it as such, we need to compensate for the difference. # # \u2028 and \u2029 are valid characters in strings in JSON, but are treated # as newline separators in JavaScript. As no newlines are allowed in # strings in JavaScript, this causes an exception. # # We fix this by replacing these unicode characters with their escaped versions. # This should be safe, as the only place they can appear is in strings anyway. # # Read more here: http://timelessrepo.com/json-isnt-a-javascript-subset # rubocop:disable Layout/IndentHeredoc wrapper_js = <<-JS (function() { var railsContext = #{rails_context(server_side: true).to_json}; #{initialize_redux_stores} var props = #{props_string(props).gsub("\u2028", '\u2028').gsub("\u2029", '\u2029')}; return ReactOnRails.serverRenderReactComponent({ name: '#{react_component_name}', domNodeId: '#{dom_id}', props: props, trace: #{trace}, railsContext: railsContext }); })() JS # rubocop:enable Layout/IndentHeredoc result = ReactOnRails::ServerRenderingPool.server_render_js_with_console_logging(wrapper_js) if result["hasErrors"] && raise_on_prerender_error # We caught this exception on our backtrace handler raise ReactOnRails::PrerenderError, component_name: react_component_name, # Sanitize as this might be browser logged props: sanitized_props_string(props), err: nil, js_code: wrapper_js, console_messages: result["consoleReplayScript"] # rubocop:enable Style/RaiseArgs end result rescue ExecJS::ProgramError => err # This error came from execJs raise ReactOnRails::PrerenderError, component_name: react_component_name, # Sanitize as this might be browser logged props: sanitized_props_string(props), err: err, js_code: wrapper_js # rubocop:enable Style/RaiseArgs end def initialize_redux_stores return "" unless @registered_stores.present? || @registered_stores_defer_render.present? declarations = "var reduxProps, store, storeGenerator;\n".dup all_stores = (@registered_stores || []) + (@registered_stores_defer_render || []) result = <<-JS.dup ReactOnRails.clearHydratedStores(); JS result << all_stores.each_with_object(declarations) do |redux_store_data, memo| store_name = redux_store_data[:store_name] props = props_string(redux_store_data[:props]) memo << <<-JS.strip_heredoc reduxProps = #{props}; storeGenerator = ReactOnRails.getStoreGenerator('#{store_name}'); store = storeGenerator(reduxProps, railsContext); ReactOnRails.setStore('#{store_name}', store); JS end result end # This is the definitive list of the default values used for the rails_context, which is the # second parameter passed to both component and store generator functions. # rubocop:disable Metrics/AbcSize def rails_context(server_side: required("server_side")) @rails_context ||= begin result = { inMailer: in_mailer?, # Locale settings i18nLocale: I18n.locale, i18nDefaultLocale: I18n.default_locale } if defined?(request) && request.present? # Check for encoding of the request's original_url and try to force-encoding the # URLs as UTF-8. This situation can occur in browsers that do not encode the # entire URL as UTF-8 already, mostly on the Windows platform (IE11 and lower). original_url_normalized = request.original_url if original_url_normalized.encoding.to_s == "ASCII-8BIT" original_url_normalized = original_url_normalized.force_encoding("ISO-8859-1").encode("UTF-8") end # Using Addressable instead of standard URI to better deal with # non-ASCII characters (see https://github.com/shakacode/react_on_rails/pull/405) uri = Addressable::URI.parse(original_url_normalized) # uri = Addressable::URI.parse("http://foo.com:3000/posts?id=30&limit=5#time=1305298413") result.merge!( # URL settings href: uri.to_s, location: "#{uri.path}#{uri.query.present? ? "?#{uri.query}" : ''}", scheme: uri.scheme, # http host: uri.host, # foo.com port: uri.port, pathname: uri.path, # /posts search: uri.query, # id=30&limit=5 httpAcceptLanguage: request.env["HTTP_ACCEPT_LANGUAGE"] ) end if ReactOnRails.configuration.rendering_extension custom_context = ReactOnRails.configuration.rendering_extension.custom_context(self) result.merge!(custom_context) if custom_context end result end @rails_context.merge(serverSide: server_side) end def replay_console_option(val) val.nil? ? ReactOnRails.configuration.replay_console : val end def use_hot_reloading? ENV["REACT_ON_RAILS_ENV"] == "HOT" end def send_tag_method(tag_method_name, args) asset_type = use_hot_reloading? ? :hot : :static assets = Array(args[asset_type]) options = args.delete_if { |key, _value| %i[hot static].include?(key) } send(tag_method_name, *assets, options) unless assets.empty? end def in_mailer? return false unless defined?(controller) return false unless defined?(ActionMailer::Base) controller.is_a?(ActionMailer::Base) end end