Skip to content

Instantly share code, notes, and snippets.

@martyphee
Created February 18, 2016 08:13
Show Gist options
  • Save martyphee/2a36d9429cb084228c7b to your computer and use it in GitHub Desktop.
Save martyphee/2a36d9429cb084228c7b to your computer and use it in GitHub Desktop.
ReactOnRails: Adding a before_render and after_render hooks to server rendering.
jed = new Jed({
"domain": "orderwebjsx",
"locale_data": {
"orderwebjsx": {
"": {
"domain": "orderwebjsx",
"plural_forms": "nplurals=2; plural=(n != 1);",
"lang": "en"
},
"Martin John Phee": [
"Jen Phee"
],
"e.g. %(postcode)s": [
"e.g. %(postcode)s"
],
"Expand your customer base without having to expand the size of your dining room. Let us handle the complexity so you dont have to.": [
"Expand your customer base without having to expand the size of your dining room. Let us handle the complexity so you dont have to."
]
}
}
});
# Shown below are the defaults for configuration
ReactOnRails.configure do |config|
# Client bundles are configured in application.js
# Server rendering:
# Server bundle is a single file for all server rendering of components.
config.server_bundle_js_file = "app/assets/javascripts/generated/server-bundle.js"
# increase if you're on JRuby
config.server_renderer_pool_size = 1
# seconds
config.server_renderer_timeout = 20
# If set to true, this forces Rails to reload the server bundle if it is modified
config.development_mode = Rails.env.development?
# For server rendering. This can be set to false so that server side messages are discarded.
# Default is true. Be cautious about turning this off.
config.replay_console = true
# Default is true. Logs server rendering messags to Rails.logger.info
config.logging_on_server = true
# The following options can be overriden by passing to the helper method:
# Default is false
config.prerender = false
# Default is false, meaning that you expose ReactComponents directly
config.generator_function = false
# Default is true for development, off otherwise
config.trace = Rails.env.development?
end
module ReactOnRails
class ServerRenderingPool
def self.before_render(component_name, props, prerender_options, request)
jedjs = ::Rails.application.assets["jed.js"].to_s
jed = ::Rails.application.assets["locale/deliveroo-#{I18n.locale}.js"].to_s
<<-JS
#{jedjs}
#{jed}
JS
end
def self.after_render(component_name, props, prerender_options, request)
"console.log('#{component_name} was rendered for host #{request.host}')"
end
end
end
require "react_on_rails/prerender_error"
module ReactOnRailsHelper
def server_rendered_react_component_html(options, props, react_component_name, dom_id)
return { 'html' => '', 'consoleReplayScript' => '' } unless prerender(options)
# 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 server-bundle
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_string = props.is_a?(String) ? props : props.to_json
wrapper_js = <<-JS
(function() {
#{ReactOnRails::ServerRenderingPool.before_render(react_component_name, props_string, options, request)}
var props = #{props_string};
var result = null;
result = ReactOnRails.serverRenderReactComponent({
componentName: '#{react_component_name}',
domId: '#{dom_id}',
props: props,
trace: #{trace(options)},
generatorFunction: #{generator_function(options)},
location: '#{request.fullpath}'
});
#{ReactOnRails::ServerRenderingPool.after_render(react_component_name, props_string, options, request)}
return result;
})()
JS
result = ReactOnRails::ServerRenderingPool.server_render_js_with_console_logging(wrapper_js)
if result["hasErrors"] && raise_on_prerender_error(options)
# We caught this exception on our backtrace handler
# rubocop:disable Style/RaiseArgs
fail ReactOnRails::PrerenderError.new(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
# rubocop:disable Style/RaiseArgs
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: wrapper_js)
# rubocop:enable Style/RaiseArgs
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) => <MyReactComponent {...props}/>;
# or using ES5
# var MyReactComponentApp = function(props) { return <YourReactComponent {...props}/>; }
# Exposing the react_component_name is necessary to both a plain ReactComponent as well as
# a generator:
# For client rendering, expose the react_component_name on window:
# window.MyReactComponentApp = MyReactComponentApp;
# For server rendering, export the react_component_name on global:
# global.MyReactComponentApp = MyReactComponentApp;
# See spec/dummy/client/app/startup/serverGlobals.jsx and
# spec/dummy/client/app/startup/ClientApp.jsx for examples of this
# props: Ruby Hash or JSON string which contains the properties to pass to the react object
#
# options:
# generator_function: <true/false> default is false, set to true if you want to use a
# generator function rather than a React Component.
# prerender: <true/false> set to false when debugging!
# trace: <true/false> set to true to print additional debugging information in the browser
# default is true for development, off otherwise
# replay_console: <true/false> 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: <true/false> 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, props = {}, 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.
# We use this react_component_index in case we have the same component multiple times on the page.
react_component_index = next_react_component_index
react_component_name = component_name.camelize # Not sure if we should be doing this (JG)
if options[:id].nil?
dom_id = "#{component_name}-react-component-#{react_component_index}"
else
dom_id = options[:id]
end
# 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.
turbolinks_loaded = Object.const_defined?(:Turbolinks)
component_specification_tag =
content_tag(:div,
"",
class: "js-react-on-rails-component",
style: "display:none",
data: {
component_name: react_component_name,
props: props,
trace: trace(options),
generator_function: generator_function(options),
expect_turbolinks: turbolinks_loaded,
dom_id: dom_id
})
# Create the HTML rendering part
result = server_rendered_react_component_html(options, props, react_component_name, dom_id)
server_rendered_html = result["html"]
console_script = result["consoleReplayScript"]
content_tag_options = options.except(:generator_function, :prerender, :trace,
:replay_console, :id, :react_component_name,
:server_side, :raise_on_prerender_error)
content_tag_options[:id] = dom_id
rendered_output = content_tag(:div,
server_rendered_html.html_safe,
content_tag_options)
# IMPORTANT: Ensure that we mark string as html_safe to avoid escaping.
<<-HTML.html_safe
#{component_specification_tag}
#{rendered_output}
#{replay_console(options) ? console_script : ''}
HTML
end
def sanitized_props_string(props)
props.is_a?(String) ? json_escape(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
(function() {
var htmlResult = '';
var consoleReplayScript = '';
var hasErrors = false;
try {
htmlResult =
(function() {
return #{js_expression};
})();
} catch(e) {
htmlResult = ReactOnRails.handleError({e: e, componentName: 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(options) ? console_log_script : ''}")
rescue ExecJS::ProgramError => err
# rubocop:disable Style/RaiseArgs
raise ReactOnRails::PrerenderError.new(component_name: "N/A (server_render_js called)",
err: err,
js_code: wrapper_js)
# rubocop:enable Style/RaiseArgs
end
private
def next_react_component_index
@react_component_index ||= -1
@react_component_index += 1
end
def raise_on_prerender_error(options)
options.fetch(:raise_on_prerender_error) { ReactOnRails.configuration.raise_on_prerender_error }
end
def trace(options)
options.fetch(:trace) { ReactOnRails.configuration.trace }
end
def generator_function(options)
options.fetch(:generator_function) { ReactOnRails.configuration.generator_function }
end
def prerender(options)
options.fetch(:prerender) { ReactOnRails.configuration.prerender }
end
def replay_console(options)
options.fetch(:replay_console) { ReactOnRails.configuration.replay_console }
end
end
import HelloWorldAppServer from './HelloWorldAppServer';
import Jed from 'jed'
global.jed = new Jed({
"domain": "orderwebjsx",
"locale_data": {
"orderwebjsx": {
}
}
});
/**
* I18n methods which use a Jed instance to make our code cleaner and to make
* calls to do string interpolation
**/
global.i18n = {
/*
* Enhance gettext so the last parameter can be an object for string
* interpolation
*
* gettext('This is a test with a string %(name)s', {name: 'string'});
* The s after the close ) is important. It won't work without.
*/
gettext: function () {
if (arguments.length > 1) {
arguments[0] = jed.gettext(arguments[0]);
return jed.sprintf.apply(jed, arguments)
} else {
return jed.gettext(arguments[0]);
}
},
/*
* Enhance ngettext so the last parameter can be an object for string
* interpolation. This makes an assumption that there will only ever be two
* pluralisations as that is the standard in english
*
* ngettext('%(seconds)d second', '%(seconds)d seconds', seconds, {seconds: number});
*/
ngettext: function () {
var translated = jed.ngettext.apply(jed, arguments);
if (arguments.length === 4) {
return jed.sprintf(translated, arguments[3])
}
return translated;
}
};
global.HelloWorldApp = HelloWorldAppServer;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment