Skip to content

Commit

Permalink
Return markup hash form react component helper (#800)
Browse files Browse the repository at this point in the history
* Allow to return object as a value of htmlResult from generator function on the server
* Implement handling of Hash result of server_rendered_react_component_html in react_on_rails helper
* Implement additional example page for server rendered HTML that shows usage of htmlResult object
* Implement test for page title rendered in generator function on server
* Add conditional title to dummy app layout
* Implement rendering title string in generator function on server
* Add test for conditional title to integration_spec
* Refactor code in react_on_rails helper
* Add some notes to API doc
* Implement example with ReactHelmet
* Now test page yields title rendered by ReactHelmet
* Add tests with disabled JS to integration spec to ensure correct title rendering on server
* Mark HTML strings other than component HTML (composed with props and console script) as html_safe
* Fix for dummy application title
* Pass params hash to #build_react_component_result_for_... methods
* Now component key in renderedHtml object expected to match component registration key
* Renamed example page and component registration
* Renamed example page to react_helmet
* Changed route to example page
* Renamed component registration to ReactHelmetApp
* Additional refactoring
* Add docs and fix comments
* Create additional-reading/react-helmet.mb
* Fix comments for generator function
* Update javascript-api.md
* Update docs
* Update README.md
* Update CHANGELOG.md
* Small fixes for JS tests from previous PR
* Updated CHANGELOG.md and README.md
* Assign params to local variables in #build_react_component_result_for_... methods
* Now rendered HTML for React component goes under the constant key componentHtml
* Implement constant key usage in #build_react_component_result_for_server_rendered_hash method of react_on_rails helper
* Updated generator function for server rendering
* Fixed additional-reading/react-helmet.md
* Updated react_helmet.erb template accordingly
* Updated javascript-api.md
* Implement required params
* Implement required params for #build_react_component_result_for... methods
* Moved component_html_key to the constant
* Returned back checking for "componentHtml" key presence
* Updated CHANGELOG.md
* Update react-helmet to v5.0.3 to resolve PropTypes warnings
  • Loading branch information
udovenko authored and justin808 committed Apr 13, 2017
1 parent 2bef1d5 commit d5d579e
Show file tree
Hide file tree
Showing 19 changed files with 303 additions and 21 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project's source code will be documented in this fil
Contributors: please follow the recommendations outlined at [keepachangelog.com](http://keepachangelog.com/). Please use the existing headings and styling as a guide, and add a link for the version diff at the bottom of the file. Also, please update the `Unreleased` link to compare to the latest release version.

## [Unreleased]
### Added
- Add an ability to return multiple HTML strings in a `Hash` as a result of `react_component` method call. Allows to build `<head>` contents with [React Helmet](https://github.com/nfl/react-helmet).
[#800](https://github.com/shakacode/react_on_rails/pull/800) by [udovenko](https://github.com/udovenko).
### Fixed
- Fix PropTypes, createClass deprecation warnings for React 15.5.x. [#804](https://github.com/shakacode/react_on_rails/pull/804) by [udovenko ](https://github.com/udovenko).

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,9 @@ Why would you create a function that returns a React component? For example, you

Another reason to use a generator function is that sometimes in server rendering, specifically with React Router, you need to return the result of calling ReactDOMServer.renderToString(element). You can do this by returning an object with the following shape: { renderedHtml, redirectLocation, error }.

For server rendering, if you wish to return multiple HTML strings from a generator function, you may return an Object from your generator function with a single top level property of renderedHtml. Inside this Object, place a key called componentHtml, along with any other needed keys. This is useful when you using side effects libraries like [React Helmet](https://github.com/nfl/react-helmet). Your Ruby code will get this Object as a Hash containing keys componentHtml and any other custom keys that you added:
{ renderedHtml: { componentHtml, customKey1, customKey2} }

#### Renderer Functions
A renderer function is a generator function that accepts three arguments: `(props, railsContext, domNodeId) => { ... }`. Instead of returning a React component, a renderer is responsible for calling `ReactDOM.render` to manually render a React component into the dom. Why would you want to call `ReactDOM.render` yourself? One possible use case is [code splitting](./docs/additional-reading/code-splitting.md).

Expand Down
98 changes: 83 additions & 15 deletions app/helpers/react_on_rails_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
module ReactOnRailsHelper
include ReactOnRails::Utils::Required

COMPONENT_HTML_KEY = "componentHtml".freeze

# 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.
Expand Down Expand Up @@ -117,21 +119,23 @@ def react_component(component_name, raw_options = {})
server_rendered_html = result["html"]
console_script = result["consoleReplayScript"]

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)

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

prepend_render_rails_context(result)
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
Expand Down Expand Up @@ -222,6 +226,70 @@ def server_render_js(js_expression, options = {})

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.
<<-HTML.html_safe
#{component_specification_tag}
#{rendered_output}
#{console_script}
HTML
end

def json_safe_and_pretty(hash_or_string)
# if Rails.env.development?
# # TODO: for json_safe_and_pretty
Expand Down
78 changes: 78 additions & 0 deletions docs/additional-reading/react-helmet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Using React Helmet to build `<head>` content

## Installation and general usage
See https://github.com/nfl/react-helmet for details. Run `yarn add react-helmet` in your `client` directory to add this package to your application.

## Example
Here is what you need to do in order to configure your Rails application to work with **ReactHelmet**.

Create generator function for server rendering like this:

```javascript
export default (props, _railsContext) => {
const componentHtml = renderToString(<App {...props} />);
const helmet = Helmet.renderStatic();

const renderedHtml = {
componentHtml,
title: helmet.title.toString(),
};
return { renderedHtml };
};
```
You can add more **helmet** properties to result, e.g. **meta**, **base** and so on. See https://github.com/nfl/react-helmet#server-usage.

Use regular component or generator function for client-side:

```javascript
export default (props, _railsContext) => (
<App {...props} />
);
```

Put **ReactHelmet** component somewhere in your `<App>`:
```javascript
import { Helmet } from 'react-helmet';

const App = (props) => (
<div>
<Helmet>
<title>Custom page title</title>
</Helmet>
...
</div>
);

export default App;
```
Register your generators for client and server sides:

```javascript
import ReactHelmetApp from '../ReactHelmetClientApp';

ReactOnRails.register({
ReactHelmetApp
});
```
```javascript
import ReactHelmetApp from '../ReactHelmetServerApp';

ReactOnRails.register({
ReactHelmetApp
});
```
Now when `react_component` helper will be called with **"ReactHelmetApp"** as a first argument it will return a hash instead of HTML string:
```ruby
<% react_helmet_app = react_component("ReactHelmetApp", prerender: true, props: { hello: "world" }, trace: true) %>
<% content_for :title do %>
<%= react_helmet_app['title'] %>
<% end %>

<%= react_helmet_app["componentHtml"] %>
```
So now we're able to insert received title tag to our application layout:
```ruby
<%= yield(:title) if content_for?(:title) %>
```
8 changes: 8 additions & 0 deletions docs/api/javascript-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ The best source of docs is the main [ReactOnRails.js](../../node_package/src/Rea
* find you components for rendering. Components get called with props, or you may use a
* "generator function" to return a React component or an object with the following shape:
* { renderedHtml, redirectLocation, error }.
* For server rendering, if you wish to return multiple HTML strings from a generator function,
* you may return an Object from your generator function with a single top level property of
* renderedHtml. Inside this Object, place a key called componentHtml, along with any other
* needed keys. This is useful when you using side effects libraries like react helmet.
* Your Ruby code with get this Object as a Hash containing keys componentHtml and any other
* custom keys that you added:
* { renderedHtml: { componentHtml, customKey1, customKey2 } }
* See the example in /docs/additional-reading/react-helmet.md
* @param components (key is component name, value is component)
*/
register(components)
Expand Down
2 changes: 1 addition & 1 deletion node_package/tests/ReactOnRails.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ test('clearHydratedStores', (assert) => {
return createStore(reducer, props);
}

ReactOnRails.setStore('storeGenerator', storeGenerator);
ReactOnRails.setStore('storeGenerator', storeGenerator({}));
const actual = new Map();
actual.set(storeGenerator);
assert.deepEqual(actual, ReactOnRails.stores());
Expand Down
2 changes: 1 addition & 1 deletion node_package/tests/StoreRegistry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ test('StoreRegistry clearHydratedStores', (assert) => {
assert.plan(2);
StoreRegistry.stores().clear();

StoreRegistry.setStore('storeGenerator', storeGenerator);
StoreRegistry.setStore('storeGenerator', storeGenerator({}));
const actual = new Map();
actual.set(storeGenerator);
assert.deepEqual(actual, StoreRegistry.stores());
Expand Down
6 changes: 5 additions & 1 deletion spec/dummy/app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<title>Dummy</title>
<% if content_for?(:title) %>
<%= yield(:title) %>
<% else %>
<title>Dummy</title>
<% end %>

<%= yield :head %>

Expand Down
3 changes: 3 additions & 0 deletions spec/dummy/app/views/pages/_header.erb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@
<li>
<%= link_to "Generator function returns object with renderedHtml", rendered_html_path %>
</li>
<li>
<%= link_to "Generator function returns object with renderedHtml as another object", react_helmet_path %>
</li>
<li>
<%= link_to "Image Example", image_example_path %>
</li>
Expand Down
15 changes: 15 additions & 0 deletions spec/dummy/app/views/pages/react_helmet.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<%= render "header" %>

<% react_helmet_app = react_component("ReactHelmetApp", prerender: true, props: { hello: "world" }, trace: true) %>

<% content_for :title do %>
<%= react_helmet_app['title'] %>
<% end %>

<%= react_helmet_app["componentHtml"] %>

<hr/>

This page demonstrates a generator function that returns htmlResult as an object
with HTML strings on the server side. It is useful to manipulating &lt;head&gt;
content. Check out the page title!
13 changes: 13 additions & 0 deletions spec/dummy/client/app/components/ReactHelmet.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import { Helmet } from 'react-helmet';

const EchoProps = (props) => (
<div>
<Helmet>
<title>Custom page title</title>
</Helmet>
Props: {JSON.stringify(props)}
</div>
);

export default EchoProps;
7 changes: 7 additions & 0 deletions spec/dummy/client/app/startup/ReactHelmetClientApp.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Top level component for simple client side only rendering
import React from 'react';
import ReactHelmet from '../components/ReactHelmet';

export default (props, _railsContext) => (
<ReactHelmet {...props} />
);
24 changes: 24 additions & 0 deletions spec/dummy/client/app/startup/ReactHelmetServerApp.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Top level component for simple client side only rendering
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Helmet } from 'react-helmet';
import ReactHelmet from '../components/ReactHelmet';

/*
* Export a function that takes the props and returns an object with { renderedHtml }
* This example shows returning renderedHtml as an object itself that contains rendered
* component markup and additional HTML strings.
*
* This is imported as "ReactHelmetApp" by "serverRegistration.jsx". Note that rendered
* component markup must go under "componentHtml" key.
*/
export default (props, _railsContext) => {
const componentHtml = renderToString(<ReactHelmet {...props} />);
const helmet = Helmet.renderStatic();

const renderedHtml = {
componentHtml,
title: helmet.title.toString(),
};
return { renderedHtml };
};
4 changes: 4 additions & 0 deletions spec/dummy/client/app/startup/clientRegistration.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import SharedReduxStore from '../stores/SharedReduxStore';
// Deferred render on the client side w/ server render
import RenderedHtml from './ClientRenderedHtml';

// Deferred render on the client side w/ server render with additional HTML strings:
import ReactHelmetApp from './ReactHelmetClientApp';

// Demonstrate using Images
import ImageExample from '../components/ImageExample';

Expand All @@ -43,6 +46,7 @@ ReactOnRails.register({
DeferredRenderApp,
CacheDisabled,
RenderedHtml,
ReactHelmetApp,
ImageExample,
});

Expand Down
4 changes: 4 additions & 0 deletions spec/dummy/client/app/startup/serverRegistration.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import DeferredRenderApp from './DeferredRenderAppServer';
// Deferred render on the client side w/ server render
import RenderedHtml from './ServerRenderedHtml';

// Deferred render on the client side w/ server render with additional HTML strings:
import ReactHelmetApp from './ReactHelmetServerApp';

// Demonstrate using Images
import ImageExample from '../components/ImageExample';

Expand All @@ -51,6 +54,7 @@ ReactOnRails.register({
CssModulesImagesFontsExample,
DeferredRenderApp,
RenderedHtml,
ReactHelmetApp,
ImageExample,
});

Expand Down
1 change: 1 addition & 0 deletions spec/dummy/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"postcss-loader": "^1.3.3",
"react": "^15.5.4",
"react-dom": "^15.5.4",
"react-helmet": "5.0.3",
"react-on-rails": "file:../../..",
"react-proptypes": "^0.0.1",
"react-redux": "^5.0.4",
Expand Down
Loading

0 comments on commit d5d579e

Please sign in to comment.