diff --git a/CHANGELOG.md b/CHANGELOG.md index 311330c20..8614e1393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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). diff --git a/README.md b/README.md index 00297568a..748e8247c 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/app/helpers/react_on_rails_helper.rb b/app/helpers/react_on_rails_helper.rb index a2755761a..3cf7bea03 100644 --- a/app/helpers/react_on_rails_helper.rb +++ b/app/helpers/react_on_rails_helper.rb @@ -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. @@ -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 @@ -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 diff --git a/docs/additional-reading/react-helmet.md b/docs/additional-reading/react-helmet.md new file mode 100644 index 000000000..d46b95c1a --- /dev/null +++ b/docs/additional-reading/react-helmet.md @@ -0,0 +1,78 @@ +# Using React Helmet to build `` 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(); + 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) => ( + +); +``` + +Put **ReactHelmet** component somewhere in your ``: +```javascript +import { Helmet } from 'react-helmet'; + +const App = (props) => ( +
+ + Custom page title + + ... +
+); + +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) %> +``` diff --git a/docs/api/javascript-api.md b/docs/api/javascript-api.md index 5a4a68201..de38a268d 100644 --- a/docs/api/javascript-api.md +++ b/docs/api/javascript-api.md @@ -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) diff --git a/node_package/tests/ReactOnRails.test.js b/node_package/tests/ReactOnRails.test.js index 22628b412..336ff42ac 100644 --- a/node_package/tests/ReactOnRails.test.js +++ b/node_package/tests/ReactOnRails.test.js @@ -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()); diff --git a/node_package/tests/StoreRegistry.test.js b/node_package/tests/StoreRegistry.test.js index 7dd32399a..a7aab6a4c 100644 --- a/node_package/tests/StoreRegistry.test.js +++ b/node_package/tests/StoreRegistry.test.js @@ -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()); diff --git a/spec/dummy/app/views/layouts/application.html.erb b/spec/dummy/app/views/layouts/application.html.erb index e270efd69..178c4f080 100644 --- a/spec/dummy/app/views/layouts/application.html.erb +++ b/spec/dummy/app/views/layouts/application.html.erb @@ -1,7 +1,11 @@ - Dummy + <% if content_for?(:title) %> + <%= yield(:title) %> + <% else %> + Dummy + <% end %> <%= yield :head %> diff --git a/spec/dummy/app/views/pages/_header.erb b/spec/dummy/app/views/pages/_header.erb index 0f410065a..007f50a53 100644 --- a/spec/dummy/app/views/pages/_header.erb +++ b/spec/dummy/app/views/pages/_header.erb @@ -77,6 +77,9 @@
  • <%= link_to "Generator function returns object with renderedHtml", rendered_html_path %>
  • +
  • + <%= link_to "Generator function returns object with renderedHtml as another object", react_helmet_path %> +
  • <%= link_to "Image Example", image_example_path %>
  • diff --git a/spec/dummy/app/views/pages/react_helmet.erb b/spec/dummy/app/views/pages/react_helmet.erb new file mode 100644 index 000000000..1418465dc --- /dev/null +++ b/spec/dummy/app/views/pages/react_helmet.erb @@ -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"] %> + +
    + +This page demonstrates a generator function that returns htmlResult as an object +with HTML strings on the server side. It is useful to manipulating <head> +content. Check out the page title! diff --git a/spec/dummy/client/app/components/ReactHelmet.jsx b/spec/dummy/client/app/components/ReactHelmet.jsx new file mode 100644 index 000000000..ac2acfee7 --- /dev/null +++ b/spec/dummy/client/app/components/ReactHelmet.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; + +const EchoProps = (props) => ( +
    + + Custom page title + + Props: {JSON.stringify(props)} +
    +); + +export default EchoProps; diff --git a/spec/dummy/client/app/startup/ReactHelmetClientApp.jsx b/spec/dummy/client/app/startup/ReactHelmetClientApp.jsx new file mode 100644 index 000000000..51643de80 --- /dev/null +++ b/spec/dummy/client/app/startup/ReactHelmetClientApp.jsx @@ -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) => ( + +); diff --git a/spec/dummy/client/app/startup/ReactHelmetServerApp.jsx b/spec/dummy/client/app/startup/ReactHelmetServerApp.jsx new file mode 100644 index 000000000..9dcf6bc34 --- /dev/null +++ b/spec/dummy/client/app/startup/ReactHelmetServerApp.jsx @@ -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(); + const helmet = Helmet.renderStatic(); + + const renderedHtml = { + componentHtml, + title: helmet.title.toString(), + }; + return { renderedHtml }; +}; diff --git a/spec/dummy/client/app/startup/clientRegistration.jsx b/spec/dummy/client/app/startup/clientRegistration.jsx index 01293582c..391a8a904 100644 --- a/spec/dummy/client/app/startup/clientRegistration.jsx +++ b/spec/dummy/client/app/startup/clientRegistration.jsx @@ -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'; @@ -43,6 +46,7 @@ ReactOnRails.register({ DeferredRenderApp, CacheDisabled, RenderedHtml, + ReactHelmetApp, ImageExample, }); diff --git a/spec/dummy/client/app/startup/serverRegistration.jsx b/spec/dummy/client/app/startup/serverRegistration.jsx index 7f0ef56d8..0eb868dee 100644 --- a/spec/dummy/client/app/startup/serverRegistration.jsx +++ b/spec/dummy/client/app/startup/serverRegistration.jsx @@ -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'; @@ -51,6 +54,7 @@ ReactOnRails.register({ CssModulesImagesFontsExample, DeferredRenderApp, RenderedHtml, + ReactHelmetApp, ImageExample, }); diff --git a/spec/dummy/client/package.json b/spec/dummy/client/package.json index 10075e5e0..4528283d9 100644 --- a/spec/dummy/client/package.json +++ b/spec/dummy/client/package.json @@ -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", diff --git a/spec/dummy/client/yarn.lock b/spec/dummy/client/yarn.lock index cf82a0e26..32a706813 100644 --- a/spec/dummy/client/yarn.lock +++ b/spec/dummy/client/yarn.lock @@ -1594,7 +1594,7 @@ decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" -deep-equal@~1.0.1: +deep-equal@^1.0.1, deep-equal@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -2033,6 +2033,10 @@ evp_bytestokey@^1.0.0: dependencies: create-hash "^1.1.1" +exenv@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.1.tgz#75de1c8dee02e952b102aa17f8875973e0df14f9" + exit-hook@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" @@ -3125,7 +3129,7 @@ lodash.isarray@^3.0.0: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" -lodash.keys@^3.0.0: +lodash.keys@^3.0.0, lodash.keys@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" dependencies: @@ -4023,7 +4027,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prop-types@^15.0.0, prop-types@^15.5.6, prop-types@^15.5.7, prop-types@~15.5.7: +prop-types@^15.0.0, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7, prop-types@~15.5.7: version "15.5.7" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.7.tgz#231c4f29cdd82e355011d4889386ca9059544dd1" dependencies: @@ -4142,6 +4146,15 @@ react-dom@^15.5.4: object-assign "^4.1.0" prop-types "~15.5.7" +react-helmet@5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-5.0.3.tgz#c6da63ee96e83aa7c8fe6d041f28dd288b1b006d" + dependencies: + deep-equal "^1.0.1" + object-assign "^4.1.1" + prop-types "^15.5.4" + react-side-effect "^1.1.0" + "react-on-rails@file:../../..": version "6.9.3" @@ -4180,6 +4193,13 @@ react-router@3.0.5: prop-types "^15.5.6" warning "^3.0.0" +react-side-effect@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-1.1.0.tgz#57209f7ebc940d55e0fda82fe51422654175d609" + dependencies: + exenv "^1.2.1" + shallowequal "^0.2.2" + react-transform-hmr@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/react-transform-hmr/-/react-transform-hmr-1.0.4.tgz#e1a40bd0aaefc72e8dfd7a7cda09af85066397bb" @@ -4600,6 +4620,12 @@ shallow-clone@^0.1.2: lazy-cache "^0.2.3" mixin-object "^2.0.1" +shallowequal@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-0.2.2.tgz#1e32fd5bcab6ad688a4812cb0cc04efc75c7014e" + dependencies: + lodash.keys "^3.1.2" + shelljs@^0.7.5: version "0.7.6" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.6.tgz#379cccfb56b91c8601e4793356eb5382924de9ad" diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 99133bb5a..5fa22f2b3 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -29,5 +29,6 @@ get "css_modules_images_fonts_example" => "pages#css_modules_images_fonts_example" get "turbolinks_cache_disabled" => "pages#turbolinks_cache_disabled" get "rendered_html" => "pages#rendered_html" + get "react_helmet" => "pages#react_helmet" get "image_example" => "pages#image_example" end diff --git a/spec/dummy/spec/features/integration_spec.rb b/spec/dummy/spec/features/integration_spec.rb index ee6a41d49..8730577ec 100644 --- a/spec/dummy/spec/features/integration_spec.rb +++ b/spec/dummy/spec/features/integration_spec.rb @@ -185,6 +185,26 @@ def change_text_expect_dom_selector(dom_selector) end end +feature "generator function returns renderedHtml as an object with additional HTML markups" do + shared_examples "renderedHtmls should not have any errors and set correct page title" do + subject { page } + background { visit react_helmet_path } + scenario "renderedHtmls should not have any errors" do + expect(subject).to have_text 'Props: {"hello":"world"}' + expect(subject).to have_css "title", text: /\ACustom page title\z/, visible: false + expect(subject.html).to include("[SERVER] RENDERED ReactHelmetApp to dom node with id") + end + end + + describe "with disabled JS" do + include_examples "renderedHtmls should not have any errors and set correct page title" + end + + describe "with enabled JS", :js do + include_examples "renderedHtmls should not have any errors and set correct page title" + end +end + feature "display images", :js do subject { page } background { visit "/image_example" }