Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Providing rails_context always to generator functions (store and component) #345

Merged
merged 1 commit into from
Mar 27, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Please see [Getting Started](#getting-started) for how to set up your Rails proj
+ [How it Works](#how-it-works)
- [Client-Side Rendering vs. Server-Side Rendering](#client-side-rendering-vs-server-side-rendering)
- [Building the Bundles](#building-the-bundles)
- [Rails Context)(#rails-context)
- [Globally Exposing Your React Components](#globally-exposing-your-react-components)
- [ReactOnRails View Helpers API](#reactonrails-view-helpers-api)
- [ReactOnRails JavaScript API](#reactonrails-javascript-api)
Expand Down Expand Up @@ -208,6 +209,84 @@ Each time you change your client code, you will need to re-generate the bundles

On Heroku deploys, the `lib/assets.rake` file takes care of running webpack during deployment. If you have used the provided generator, these bundles will automatically be added to your `.gitignore` in order to prevent extraneous noise from re-generated code in your pull requests. You will want to do this manually if you do not use the provided generator.

### Rails Context
When you use a "generator function" to create react components or you used shared redux stores, you get 2 params passed to your function:

1. Props that you pass in the view helper of either `react_component` or `redux_store`
2. Rails contextual information, such as the current pathname. You can customize this in your config file.

This information should be the same regardless of either client or server side rendering.

While you could manually pass this information in as "props", the rails_context is a convenience because it's pass consistently to all invocations of generator functions.

So if you register your generator function `MyAppComponent`, it will get called like:

```js
reactComponent = MyAppComponent(props, railsContext);
```
and for a store:

```js
reduxStore = MyReduxStore(props, railsContext);
```

The `railsContext` has: (see implementation in file react_on_rails_helper.rb for method rails_context for the definitive list).

```ruby
{
# URL settings
href: request.original_url,
location: "#{uri.path}#{uri.query.present? ? "?#{uri.query}": ""}",
scheme: uri.scheme, # http
host: uri.host, # foo.com
pathname: uri.path, # /posts
search: uri.query, # id=30&limit=5

# Locale settings
i18nLocale: I18n.locale,
i18nDefaultLocale: I18n.default_locale,
httpAcceptLanguage: request.env["HTTP_ACCEPT_LANGUAGE"]
}
```

#### Use Cases
##### Needing the current url path for server rendering
Suppose you want to display a nav bar with the current navigation link highlighted by the URL. When you server render the code, you will need to know the current URL/path if that is what you want your logic to be based on. This could be added to props, or ReactOnRails can add this automatically as another param to the generator function (or maybe an additional object, where we'll consider other additional bits of system info from Rails, like maybe the locale, later).

##### Needing the I18n.locale
Suppose you want to server render your react components with a the current Rails locale. We need to pass the I18n.locale to the view rendering.


#### Customization of the rails_context
You can customize the values passed in the rails_context in your `config/initializers/react_on_rails.rb`


Set the class for the `rendering_extension`:

```ruby
config.rendering_extension = RenderingExtension
```

Implement it like this above in the same file. Create a class method on the module called `custom_context` that takes the `view_context` for a param.

```ruby
module RenderingExtension

# Return a Hash that contains custom values from the view context that will get merged with
# the standard rails_context values and passed to all calls to generator functions used by the
# react_component and redux_store view helpers
def self.custom_context(view_context)
{
somethingUseful: view_context.session[:something_useful]
}
end
end
```

In this case, a prop and value for `somethingUseful` will go into the railsContext passed to all react_component and redux_store calls.

Since you can't access the rails session from JavaScript (or other values available in the view rendering context), this might useful.

### Globally Exposing Your React Components
Place your JavaScript code inside of the provided `client/app` folder. Use modules just as you would when using webpack alone. The difference here is that instead of mounting React components directly to an element using `React.render`, you **expose your components globally and then mount them with helpers inside of your Rails views**.

Expand Down Expand Up @@ -479,9 +558,15 @@ You may wish to have 2 React components share the same the Redux store. For exam

Suppose the Redux store is called `appStore`, and you have 3 React components that each need to connect to a store: `NavbarApp`, `CommentsApp`, and `BlogsApp`. I named them with `App` to indicate that they are the registered components.

You will need to make a function that can create the store you will be using for all components and register it via the `registerStore` method. Note, this is a **storeCreator**, meaning that it is a function that takes props and returns a store:
You will need to make a function that can create the store you will be using for all components and register it via the `registerStore` method. Note, this is a **storeCreator**, meaning that it is a function that takes (props, location) and returns a store:

```
function appStore(props, railsContext) {
// Create a hydrated redux store, using props and the railsContext (object with
// Rails contextual information).
return myAppStore;
}

ReactOnRails.registerStore({
appStore
});
Expand Down
72 changes: 63 additions & 9 deletions app/helpers/react_on_rails_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def react_component(component_name, options = {}, other_options = nil)
# server has already rendered the HTML.
# We use this react_component_index in case we have the same component multiple times on the page.

# TODO: remove this
options, props = parse_options_props(component_name, options, other_options)

react_component_index = next_react_component_index
Expand Down Expand Up @@ -139,11 +140,13 @@ def react_component(component_name, options = {}, other_options = nil)
content_tag_options)

# IMPORTANT: Ensure that we mark string as html_safe to avoid escaping.
<<-HTML.html_safe
result = <<-HTML.html_safe
#{component_specification_tag}
#{rendered_output}
#{replay_console(options) ? console_script : ''}
HTML

prepend_render_rails_context(result)
end

# Separate initialization of store from react_component allows multiple react_component calls to
Expand All @@ -165,7 +168,8 @@ def redux_store(store_name, props: {}, defer: false)
else
@registered_stores ||= []
@registered_stores << redux_store_data
render_redux_store_data(redux_store_data)
result = render_redux_store_data(redux_store_data)
prepend_render_rails_context(result)
end
end

Expand Down Expand Up @@ -233,12 +237,31 @@ def server_render_js(js_expression, options = {})

private

# 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: rails_context
}

@rendered_rails_context = true

rails_context_content = content_tag(:div,
"",
id: "js-react-on-rails-context",
style: ReactOnRails.configuration.skip_display_none ? nil : "display:none",
data: data)
"#{rails_context_content}\n#{render_value}".html_safe
end

def render_redux_store_data(redux_store_data)
content_tag(:div,
"",
class: "js-react-on-rails-store",
style: ReactOnRails.configuration.skip_display_none ? nil : "display:none",
data: redux_store_data)
result = content_tag(:div,
"",
class: "js-react-on-rails-store",
style: ReactOnRails.configuration.skip_display_none ? nil : "display:none",
data: redux_store_data)
prepend_render_rails_context(result)
end

def next_react_component_index
Expand All @@ -265,14 +288,15 @@ def server_rendered_react_component_html(options, props, react_component_name, d

wrapper_js = <<-JS
(function() {
var railsContext = #{rails_context.to_json};
#{initialize_redux_stores}
var props = #{props_string(props)};
return ReactOnRails.serverRenderReactComponent({
name: '#{react_component_name}',
domNodeId: '#{dom_id}',
props: props,
trace: #{trace(options)},
location: '#{request.fullpath}'
railsContext: railsContext
});
})()
JS
Expand Down Expand Up @@ -314,13 +338,43 @@ def initialize_redux_stores
memo << <<-JS
reduxProps = #{props};
storeGenerator = ReactOnRails.getStoreGenerator('#{store_name}');
store = storeGenerator(reduxProps);
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.
def rails_context
@rails_context ||= begin
uri = URI.parse(request.original_url)
# uri = URI("http://foo.com/posts?id=30&limit=5#time=1305298413")

result = {
# URL settings
href: request.original_url,
location: "#{uri.path}#{uri.query.present? ? "?#{uri.query}" : ''}",
scheme: uri.scheme, # http
host: uri.host, # foo.com
pathname: uri.path, # /posts
search: uri.query, # id=30&limit=5

# Locale settings
i18nLocale: I18n.locale,
i18nDefaultLocale: I18n.default_locale,
httpAcceptLanguage: request.env["HTTP_ACCEPT_LANGUAGE"]
}

if ReactOnRails.configuration.rendering_extension
custom_context = ReactOnRails.configuration.rendering_extension.custom_context(self)
result.merge!(custom_context) if custom_context
end
result
end
end

def raise_on_prerender_error(options)
options.fetch(:raise_on_prerender_error) { ReactOnRails.configuration.raise_on_prerender_error }
end
Expand Down
8 changes: 5 additions & 3 deletions docs/additional-reading/react-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ However, when attempting to use server-rendering, it is necessary to take steps
If you are working with the HelloWorldApp created by the react_on_rails generator, then the code below corresponds to the module in `client/app/bundles/HelloWorld/startup/HelloWorldAppServer.jsx`.

```js
const RouterApp = (props, location) => {
const store = createStore(props);

const RouterApp = (props, railsContext) => {
let error;
let redirectLocation;
let routeProps;
const { location } = railsContext;

// create your hydrated store
const store = createStore(props);

// See https://github.com/reactjs/react-router/blob/master/docs/guides/advanced/ServerRendering.md
match({ routes, location }, (_error, _redirectLocation, _routeProps) => {
Expand Down
9 changes: 6 additions & 3 deletions lib/react_on_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ def self.configuration
server_renderer_pool_size: 1,
server_renderer_timeout: 20,
skip_display_none: false,
webpack_generated_files: []
webpack_generated_files: [],
rendering_extension: nil
)
end

Expand All @@ -65,14 +66,15 @@ class Configuration
:logging_on_server, :server_renderer_pool_size,
:server_renderer_timeout, :raise_on_prerender_error,
:skip_display_none, :generated_assets_dirs, :generated_assets_dir,
:webpack_generated_files
:webpack_generated_files, :rendering_extension

def initialize(server_bundle_js_file: nil, prerender: nil, replay_console: nil,
trace: nil, development_mode: nil,
logging_on_server: nil, server_renderer_pool_size: nil,
server_renderer_timeout: nil, raise_on_prerender_error: nil,
skip_display_none: nil, generated_assets_dirs: nil,
generated_assets_dir: nil, webpack_generated_files: nil)
generated_assets_dir: nil, webpack_generated_files: nil,
rendering_extension: nil)
self.server_bundle_js_file = server_bundle_js_file
self.generated_assets_dirs = generated_assets_dirs
self.generated_assets_dir = generated_assets_dir
Expand All @@ -94,6 +96,7 @@ def initialize(server_bundle_js_file: nil, prerender: nil, replay_console: nil,
self.server_renderer_timeout = server_renderer_timeout # seconds

self.webpack_generated_files = webpack_generated_files
self.rendering_extension = rendering_extension
end
end
end
33 changes: 22 additions & 11 deletions node_package/src/clientStartup.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,38 +21,38 @@ function turbolinksInstalled() {
return (typeof Turbolinks !== 'undefined');
}

function forEachComponent(fn) {
forEach(fn, REACT_ON_RAILS_COMPONENT_CLASS_NAME);
function forEachComponent(fn, railsContext) {
forEach(fn, REACT_ON_RAILS_COMPONENT_CLASS_NAME, railsContext);
}

function forEachStore(fn) {
forEach(fn, REACT_ON_RAILS_STORE_CLASS_NAME);
function forEachStore(railsContext) {
forEach(initializeStore, REACT_ON_RAILS_STORE_CLASS_NAME, railsContext);
}

function forEach(fn, className) {
function forEach(fn, className, railsContext) {
const els = document.getElementsByClassName(className);
for (let i = 0; i < els.length; i++) {
fn(els[i]);
fn(els[i], railsContext);
}
}

function turbolinksVersion5() {
return (typeof Turbolinks.controller !== 'undefined');
}

function initializeStore(el) {
function initializeStore(el, railsContext) {
const name = el.getAttribute('data-store-name');
const props = JSON.parse(el.getAttribute('data-props'));
const storeGenerator = ReactOnRails.getStoreGenerator(name);
const store = storeGenerator(props);
const store = storeGenerator(props, railsContext);
ReactOnRails.setStore(name, store);
}

/**
* Used for client rendering by ReactOnRails
* @param el
*/
function render(el) {
function render(el, railsContext) {
const name = el.getAttribute('data-component-name');
const domNodeId = el.getAttribute('data-dom-id');
const props = JSON.parse(el.getAttribute('data-props'));
Expand All @@ -66,6 +66,7 @@ function render(el) {
props,
domNodeId,
trace,
railsContext
});

if (isRouterResult(reactElementOrRouterResult)) {
Expand All @@ -85,11 +86,21 @@ You should return a React.Component always for the client side entry point.`);
}
}

function parseRailsContext() {
const el = document.getElementById('js-react-on-rails-context');
if (el) {
return JSON.parse(el.getAttribute('data-rails-context'));
} else {
return null;
}
}

function reactOnRailsPageLoaded() {
debugTurbolinks('reactOnRailsPageLoaded');

forEachStore(initializeStore);
forEachComponent(render);
const railsContext = parseRailsContext();
forEachStore(railsContext);
forEachComponent(render, railsContext);
}

function unmount(el) {
Expand Down
Loading