# frozen_string_literal: true # rubocop:disable Metrics/ModuleLength # rubocop:disable Metrics/MethodLength # 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" require "active_support/concern" module ReactOnRails module Helper include ReactOnRails::Utils::Required COMPONENT_HTML_KEY = "componentHtml" # react_component_name: can be a React function or class component or a "Render-Function". # "Render-Functions" differ from a React function in that they take two parameters, the # props and the railsContext, like this: # # let MyReactComponentApp = (props, railsContext) => ; # # Alternately, you can define the Render-Function with an additional property # `.renderFunction = true`: # # let MyReactComponentApp = (props) => ; # MyReactComponent.renderFunction = true; # # 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/packs/server-bundle.js and # spec/dummy/client/app/packs/client-bundle.js 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. # random_dom_id can be set to override the default from the config/initializers. That's only # used if you have multiple instance of the same component on the Rails view. def react_component(component_name, options = {}) internal_result = internal_react_component(component_name, options) server_rendered_html = internal_result[:result]["html"] console_script = internal_result[:result]["consoleReplayScript"] case server_rendered_html when String build_react_component_result_for_server_rendered_string( server_rendered_html: server_rendered_html, component_specification_tag: internal_result[:tag], console_script: console_script, render_options: internal_result[:render_options] ) when Hash msg = <<~MSG Use react_component_hash (not react_component) to return a Hash to your ruby view code. See https://github.com/shakacode/react_on_rails/blob/master/spec/dummy/client/app/startup/ReactHelmetServerApp.jsx for an example of the necessary javascript configuration. MSG raise ReactOnRails::Error, msg else class_name = server_rendered_html.class.name msg = <<~MSG ReactOnRails: server_rendered_html is expected to be a String or Hash for #{component_name}. Type is #{class_name} Value: #{server_rendered_html} If you're trying to use a Render-Function to return a Hash to your ruby view code, then use react_component_hash instead of react_component and see https://github.com/shakacode/react_on_rails/blob/master/spec/dummy/client/app/startup/ReactHelmetServerApp.jsx for an example of the JavaScript code. MSG raise ReactOnRails::Error, msg end end # react_component_hash is used to return multiple HTML strings for server rendering, such as for # adding meta-tags to a page. # It is exactly like react_component except for the following: # 1. prerender: true is automatically added, as this method doesn't make sense for client only # rendering. # 2. Your JavaScript Render-Function for server rendering must return an Object rather than a React component. # 3. Your view code must expect an object and not a string. # # Here is an example of the view code: # <% react_helmet_app = react_component_hash("ReactHelmetApp", prerender: true, # props: { helloWorldData: { name: "Mr. Server Side Rendering"}}, # id: "react-helmet-0", trace: true) %> # <% content_for :title do %> # <%= react_helmet_app['title'] %> # <% end %> # <%= react_helmet_app["componentHtml"] %> # def react_component_hash(component_name, options = {}) options[:prerender] = true internal_result = internal_react_component(component_name, options) server_rendered_html = internal_result[:result]["html"] console_script = internal_result[:result]["consoleReplayScript"] if server_rendered_html.is_a?(String) && internal_result[:result]["hasErrors"] server_rendered_html = { COMPONENT_HTML_KEY => internal_result[:result]["html"] } end if server_rendered_html.is_a?(Hash) build_react_component_result_for_server_rendered_hash( server_rendered_html: server_rendered_html, component_specification_tag: internal_result[:tag], console_script: console_script, render_options: internal_result[:render_options] ) else msg = <<~MSG Render-Function used by react_component_hash for #{component_name} is expected to return an Object. See https://github.com/shakacode/react_on_rails/blob/master/spec/dummy/client/app/startup/ReactHelmetServerApp.jsx for an example of the JavaScript code. Note, your Render-Function must either take 2 params or have the property `.renderFunction = true` added to it to distinguish it from a React Function Component. MSG raise ReactOnRails::Error, msg end end # Separate initialization of store from react_component allows multiple react_component calls to # use the same Redux store. # # NOTE: This technique not recommended as it prevents dynamic code splitting for performance. # Instead, you should use the standard react_component view helper. # # 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(+"") 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. # Options include:{ prerender:, trace:, raise_on_prerender_error:, throw_js_errors: } def server_render_js(js_expression, options = {}) render_options = ReactOnRails::ReactComponent::RenderOptions .new(react_component_name: "generic-js", options: options) js_code = <<-JS.strip_heredoc (function() { var htmlResult = ''; var consoleReplayScript = ''; var hasErrors = false; var renderingError = null; var renderingErrorObject = {}; try { htmlResult = (function() { return #{js_expression}; })(); } catch(e) { renderingError = e; if (#{render_options.throw_js_errors}) { throw e; } htmlResult = ReactOnRails.handleError({e: e, name: null, jsCode: '#{escape_javascript(js_expression)}', serverSide: true}); hasErrors = true; renderingErrorObject = { message: renderingError.message, stack: renderingError.stack, } } consoleReplayScript = ReactOnRails.buildConsoleReplay(); return JSON.stringify({ html: htmlResult, consoleReplayScript: consoleReplayScript, hasErrors: hasErrors, renderingError: renderingErrorObject }); })() JS result = ReactOnRails::ServerRenderingPool .server_render_js_with_console_logging(js_code, render_options) html = result["html"] console_log_script = result["consoleLogScript"] raw("#{html}#{render_options.replay_console ? console_log_script : ''}") rescue ExecJS::ProgramError => err raise ReactOnRails::PrerenderError.new(component_name: "N/A (server_render_js called)", err: err, js_code: js_code) end def json_safe_and_pretty(hash_or_string) return "{}" if hash_or_string.nil? unless hash_or_string.is_a?(String) || hash_or_string.is_a?(Hash) raise ReactOnRails::Error, "#{__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 # 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 Render-Functions. # This method can be called from views and from the controller, as `helpers.rails_context` # # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity def rails_context(server_side: true) # ALERT: Keep in sync with node_package/src/types/index.ts for the properties of RailsContext @rails_context ||= begin result = { railsEnv: Rails.env, inMailer: in_mailer?, # Locale settings i18nLocale: I18n.locale, i18nDefaultLocale: I18n.default_locale, rorVersion: ReactOnRails::VERSION, # TODO: v13 just use the version if existing rorPro: ReactOnRails::Utils.react_on_rails_pro? } if ReactOnRails::Utils.react_on_rails_pro? result[:rorProVersion] = ReactOnRails::Utils.react_on_rails_pro_version end 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 # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity 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"), render_options: required("render_options") ) content_tag_options = render_options.html_options if content_tag_options.key?(:tag) content_tag_options_html_tag = content_tag_options[:tag] content_tag_options.delete(:tag) else content_tag_options_html_tag = "div" end content_tag_options[:id] = render_options.dom_id rendered_output = content_tag(content_tag_options_html_tag.to_sym, server_rendered_html.html_safe, content_tag_options) result_console_script = render_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"), render_options: required("render_options") ) content_tag_options = render_options.html_options content_tag_options[:id] = render_options.dom_id unless server_rendered_html[COMPONENT_HTML_KEY] raise ReactOnRails::Error, "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 = render_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. <<~HTML.html_safe #{rendered_output} #{component_specification_tag} #{console_script} HTML 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 internal_react_component(react_component_name, 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. render_options = ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name, options: 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(render_options.client_props).html_safe, type: "application/json", class: "js-react-on-rails-component", "data-component-name" => render_options.react_component_name, "data-trace" => (render_options.trace ? true : nil), "data-dom-id" => render_options.dom_id) # Create the HTML rendering part result = server_rendered_react_component(render_options) { render_options: render_options, tag: component_specification_tag, result: result } 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 object with values that are NOT html_safe! def server_rendered_react_component(render_options) return { "html" => "", "consoleReplayScript" => "" } unless render_options.prerender react_component_name = render_options.react_component_name props = render_options.props # 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 js_code = ReactOnRails::ServerRenderingJsCode.server_rendering_component_js_code( props_string: props_string(props).gsub("\u2028", '\u2028').gsub("\u2029", '\u2029'), rails_context: rails_context(server_side: true).to_json, redux_stores: initialize_redux_stores, react_component_name: react_component_name, render_options: render_options ) begin result = ReactOnRails::ServerRenderingPool.server_render_js_with_console_logging(js_code, render_options) rescue StandardError => err # This error came from the renderer raise ReactOnRails::PrerenderError.new(component_name: react_component_name, # Sanitize as this might be browser logged props: sanitized_props_string(props), err: err, js_code: js_code) end if result["hasErrors"] && render_options.raise_on_prerender_error # We caught this exception on our backtrace handler raise ReactOnRails::PrerenderError.new(component_name: react_component_name, # Sanitize as this might be browser logged props: sanitized_props_string(props), err: nil, js_code: js_code, console_messages: result["consoleReplayScript"]) end result end def initialize_redux_stores result = +<<-JS ReactOnRails.clearHydratedStores(); JS return result unless @registered_stores.present? || @registered_stores_defer_render.present? declarations = +"var reduxProps, store, storeGenerator;\n" all_stores = (@registered_stores || []) + (@registered_stores_defer_render || []) 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 def replay_console_option(val) val.nil? ? ReactOnRails.configuration.replay_console : val end def in_mailer? return false unless defined?(controller) return false unless defined?(ActionMailer::Base) controller.is_a?(ActionMailer::Base) end if defined?(ScoutApm) include ScoutApm::Tracer instrument_method :react_component, type: "ReactOnRails", name: "react_component" instrument_method :react_component_hash, type: "ReactOnRails", name: "react_component_hash" end end end # rubocop:enable Metrics/ModuleLength # rubocop:enable Metrics/MethodLength