From 4cf969ae0f5b086a1f9f1b3f438d8501a495115a Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Tue, 17 Jan 2017 20:50:28 -1000 Subject: [PATCH] Added property renderedHtml to return gen func This is to support React Router v4, which needs the result of calling renderToString. --- README.md | 6 +- docs/additional-reading/react-router.md | 65 +++++++++++++++++++ docs/api/javascript-api.md | 4 +- node_package/src/clientStartup.js | 2 +- ...eateReactElementResultNonReactComponent.js | 6 ++ node_package/src/isRouterResult.js | 5 -- .../src/serverRenderReactComponent.js | 24 +++++-- .../tests/serverRenderReactComponent.test.js | 17 +++++ spec/dummy/Procfile | 6 ++ spec/dummy/app/views/pages/_header.erb | 3 + spec/dummy/app/views/pages/index.html.erb | 8 +++ spec/dummy/app/views/pages/rendered_html.erb | 7 ++ .../dummy/client/app/components/EchoProps.jsx | 9 +++ .../client/app/startup/ClientRenderedHtml.jsx | 17 +++++ .../client/app/startup/ServerRenderedHtml.jsx | 21 ++++++ .../client/app/startup/clientRegistration.jsx | 6 +- .../client/app/startup/serverRegistration.jsx | 4 ++ spec/dummy/config/routes.rb | 1 + spec/dummy/spec/features/integration_spec.rb | 9 +++ 19 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 node_package/src/isCreateReactElementResultNonReactComponent.js delete mode 100644 node_package/src/isRouterResult.js create mode 100644 spec/dummy/app/views/pages/rendered_html.erb create mode 100644 spec/dummy/client/app/components/EchoProps.jsx create mode 100644 spec/dummy/client/app/startup/ClientRenderedHtml.jsx create mode 100644 spec/dummy/client/app/startup/ServerRenderedHtml.jsx diff --git a/README.md b/README.md index 7450b3eee..7ba79531c 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ On production deployments that use asset precompilation, such as Heroku deployme If you have used the provided generator, these bundles will automatically be added to your `.gitignore` 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: +When you use a "generator function" to create react components (or renderedHtml on the server) 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. @@ -318,6 +318,8 @@ If you do want different code to run, you'd setup a separate webpack compilation #### Generator Functions Why would you create a function that returns a React component? For example, you may want the ability to use the passed-in props to initialize a redux store or setup react-router. Or you may want to return different components depending on what's in the props. ReactOnRails will automatically detect a registered generator function. +Another reason to user 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 }. + #### 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). @@ -340,7 +342,7 @@ react_component(component_name, html_options: {}) ``` -+ **component_name:** Can be a React component, created using a ES6 class, or `React.createClass`, a generator function that returns a React component, or a renderer function that manually renders a React component to the dom (client side only). ++ **component_name:** Can be a React component, created using a ES6 class, or `React.createClass`, a generator function that returns a React component (or only on the server side, an object with shape { redirectLocation, error, renderedHtml }), or a renderer function that manually renders a React component to the dom (client side only). + **options:** + **props:** Ruby Hash which contains the properties to pass to the react object, or a JSON string. If you pass a string, we'll escape it for you. + **prerender:** enable server-side rendering of component. Set to false when debugging! diff --git a/docs/additional-reading/react-router.md b/docs/additional-reading/react-router.md index b4a335416..846bb5486 100644 --- a/docs/additional-reading/react-router.md +++ b/docs/additional-reading/react-router.md @@ -46,3 +46,68 @@ For a fleshed out integration of react_on_rails with react-router, check out [Re * [react-webpack-rails-tutorial/client/app/bundles/comments/startup/ClientRouterApp.jsx](https://github.com/shakacode/react-webpack-rails-tutorial/blob/master/client/app/bundles/comments/startup/ClientRouterApp.jsx) * [react-webpack-rails-tutorial/client/app/bundles/comments/startup/ServerRouterApp.jsx](https://github.com/shakacode/react-webpack-rails-tutorial/blob/master/client/app/bundles/comments/startup/ServerRouterApp.jsx) + + +# Server Rendering Using React Router V4 + +Your generator function may not return an object with the property `renderedHtml`. Thus, you call +renderToString() and return an object with this property. + +This example **only applies to server rendering** and should be only used in the server side bundle. + +From the [original example in the ReactRouter docs](https://react-router.now.sh/ServerRouter) + +```javascript + import React from 'react' + import { renderToString } from 'react-dom/server' + import { ServerRouter, createServerRenderContext } from 'react-router' + + const ReactRouterComponent = (props, railsContext) => { + + // first create a context for , it's where we keep the + // results of rendering for the second pass if necessary + const context = createServerRenderContext() + const { location } = railsContext; + + // render the first time + let markup = renderToString( + + + + ) + + // get the result + const result = context.getResult() + + // the result will tell you if it redirected, if so, we ignore + // the markup and send a proper redirect. + if (result.redirect) { + return { + redirectLocation: result.redirect.pathname + }; + } else { + + // the result will tell you if there were any misses, if so + // we can send a 404 and then do a second render pass with + // the context to clue the components into rendering + // this time (on the client they know from componentDidMount) + if (result.missed) { + // React on Rails does not support the 404 status code for the browser. + // res.writeHead(404) + + markup = renderToString( + + + + ) + } + return { renderedHtml: markup }; + } + } +``` diff --git a/docs/api/javascript-api.md b/docs/api/javascript-api.md index 49f608429..104f5f954 100644 --- a/docs/api/javascript-api.md +++ b/docs/api/javascript-api.md @@ -4,7 +4,9 @@ The best source of docs is the main [ReactOnRails.js](../../node_package/src/Rea ```js /** * Main entry point to using the react-on-rails npm package. This is how Rails will be able to - * find you components for rendering. + * 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 }. * @param components (key is component name, value is component) */ register(components) diff --git a/node_package/src/clientStartup.js b/node_package/src/clientStartup.js index cbfbb55bb..5bb8b4dfc 100644 --- a/node_package/src/clientStartup.js +++ b/node_package/src/clientStartup.js @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import createReactElement from './createReactElement'; -import isRouterResult from './isRouterResult'; +import isRouterResult from './isCreateReactElementResultNonReactComponent'; const REACT_ON_RAILS_COMPONENT_CLASS_NAME = 'js-react-on-rails-component'; const REACT_ON_RAILS_STORE_CLASS_NAME = 'js-react-on-rails-store'; diff --git a/node_package/src/isCreateReactElementResultNonReactComponent.js b/node_package/src/isCreateReactElementResultNonReactComponent.js new file mode 100644 index 000000000..c5ea39cf9 --- /dev/null +++ b/node_package/src/isCreateReactElementResultNonReactComponent.js @@ -0,0 +1,6 @@ +export default function isResultNonReactComponent(reactElementOrRouterResult) { + return !!( + reactElementOrRouterResult.renderedHtml || + reactElementOrRouterResult.redirectLocation || + reactElementOrRouterResult.error); +} diff --git a/node_package/src/isRouterResult.js b/node_package/src/isRouterResult.js deleted file mode 100644 index 9394feb77..000000000 --- a/node_package/src/isRouterResult.js +++ /dev/null @@ -1,5 +0,0 @@ -export default function isRouterResult(reactElementOrRouterResult) { - return !!( - reactElementOrRouterResult.redirectLocation || - reactElementOrRouterResult.error); -} diff --git a/node_package/src/serverRenderReactComponent.js b/node_package/src/serverRenderReactComponent.js index 74d5d9f00..db6588501 100644 --- a/node_package/src/serverRenderReactComponent.js +++ b/node_package/src/serverRenderReactComponent.js @@ -2,7 +2,8 @@ import ReactDOMServer from 'react-dom/server'; import ComponentRegistry from './ComponentRegistry'; import createReactElement from './createReactElement'; -import isRouterResult from './isRouterResult'; +import isCreateReactElementResultNonReactComponent from + './isCreateReactElementResultNonReactComponent'; import buildConsoleReplay from './buildConsoleReplay'; import handleError from './handleError'; @@ -28,20 +29,29 @@ See https://github.com/shakacode/react_on_rails#renderer-functions`); railsContext, }); - if (isRouterResult(reactElementOrRouterResult)) { + if (isCreateReactElementResultNonReactComponent(reactElementOrRouterResult)) { // We let the client side handle any redirect // Set hasErrors in case we want to throw a Rails exception hasErrors = !!reactElementOrRouterResult.routeError; + if (hasErrors) { console.error( `React Router ERROR: ${JSON.stringify(reactElementOrRouterResult.routeError)}`, ); - } else if (trace) { - const redirectLocation = reactElementOrRouterResult.redirectLocation; - const redirectPath = redirectLocation.pathname + redirectLocation.search; - console.log(`\ + } + + if (reactElementOrRouterResult.redirectLocation) { + if (trace) { + const redirectLocation = reactElementOrRouterResult.redirectLocation; + const redirectPath = redirectLocation.pathname + redirectLocation.search; + console.log(`\ ROUTER REDIRECT: ${name} to dom node with id: ${domNodeId}, redirect to ${redirectPath}`, - ); + ); + } + // For redirects on server rendering, we can't stop Rails from returning the same result. + // Possibly, someday, we could have the rails server redirect. + } else { + htmlResult = reactElementOrRouterResult.renderedHtml; } } else { htmlResult = ReactDOMServer.renderToString(reactElementOrRouterResult); diff --git a/node_package/tests/serverRenderReactComponent.test.js b/node_package/tests/serverRenderReactComponent.test.js index 86623f931..d94f852dd 100644 --- a/node_package/tests/serverRenderReactComponent.test.js +++ b/node_package/tests/serverRenderReactComponent.test.js @@ -38,6 +38,23 @@ test('serverRenderReactComponent renders errors', (assert) => { assert.ok(hasErrors, 'serverRenderReactComponent should have errors if exception thrown'); }); +test('serverRenderReactComponent renders html', (assert) => { + assert.plan(3); + const expectedHtml = '
Hello
'; + const X3 = () => ({ renderedHtml: expectedHtml }); + + ComponentStore.register({ X3 }); + + assert.comment('Expect to see renderedHtml'); + + const { html, hasErrors, renderedHtml } = + JSON.parse(serverRenderReactComponent({ name: 'X3', domNodeId: 'myDomId', trace: false })); + + assert.ok(html === expectedHtml, 'serverRenderReactComponent HTML should render renderedHtml value'); + assert.ok(!hasErrors, 'serverRenderReactComponent should not have errors if no exception thrown'); + assert.ok(!hasErrors, 'serverRenderReactComponent should have errors if exception thrown'); +}); + test('serverRenderReactComponent renders an error if attempting to render a renderer', (assert) => { assert.plan(1); const X3 = (a1, a2, a3) => null; diff --git a/spec/dummy/Procfile b/spec/dummy/Procfile index 008ba4d87..60581e734 100644 --- a/spec/dummy/Procfile +++ b/spec/dummy/Procfile @@ -1 +1,7 @@ rails: REACT_ON_RAILS_ENV=HOT rails s -b 0.0.0.0 + +# Build client assets, watching for changes. +rails-client-assets: rm app/assets/webpack/* || true && npm run build:dev:client + +# Build server assets, watching for changes. Remove if not server rendering. +rails-server-assets: npm run build:dev:server diff --git a/spec/dummy/app/views/pages/_header.erb b/spec/dummy/app/views/pages/_header.erb index a0834dcf8..dfd8cc033 100644 --- a/spec/dummy/app/views/pages/_header.erb +++ b/spec/dummy/app/views/pages/_header.erb @@ -74,5 +74,8 @@
  • <%= link_to "Turbolinks Cache Disabled Example", turbolinks_cache_disabled_path %>
  • +
  • + <%= link_to "Generator function returns object with renderedHtml", rendered_html_path %> +

  • diff --git a/spec/dummy/app/views/pages/index.html.erb b/spec/dummy/app/views/pages/index.html.erb index 24b58c3ad..1c8ed4942 100644 --- a/spec/dummy/app/views/pages/index.html.erb +++ b/spec/dummy/app/views/pages/index.html.erb @@ -69,6 +69,14 @@ This page demonstrates a few things the other pages do not show: <%= react_component("HelloWorldApp", props: @app_props_hello_again, prerender: false, trace: true, id: "HelloWorldApp-react-component-3") %>
    +

    Component that returns string html on server

    +
    +  <%%= react_component("HelloWorld", props: @app_props_hello, prerender: false, trace: true, id: "HelloWorld-react-component-4") %>
    +  <%%= react_component("HelloES5", props: @app_props_hello, prerender: false, trace: true, id: "HelloES5-react-component-5") %>
    +
    +<%= react_component("RenderedHtml", prerender: true, trace: true, id: "HelloWorld-react-component-4") %> +
    +

    Simple Component Without Redux

       <%%= react_component("HelloWorld", props: @app_props_hello, prerender: false, trace: true, id: "HelloWorld-react-component-4") %>
    diff --git a/spec/dummy/app/views/pages/rendered_html.erb b/spec/dummy/app/views/pages/rendered_html.erb
    new file mode 100644
    index 000000000..c776d267e
    --- /dev/null
    +++ b/spec/dummy/app/views/pages/rendered_html.erb
    @@ -0,0 +1,7 @@
    +<%= render "header" %>
    +
    +<%= react_component("RenderedHtml", prerender: true, props: { hello: "world" }, trace: true) %>
    +
    +
    + +This page demonstrates a component that returns renderToString on the server side. diff --git a/spec/dummy/client/app/components/EchoProps.jsx b/spec/dummy/client/app/components/EchoProps.jsx new file mode 100644 index 000000000..ad1ef9e6a --- /dev/null +++ b/spec/dummy/client/app/components/EchoProps.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const EchoProps = (props) => ( +
    + Props: {JSON.stringify(props)} +
    +); + +export default EchoProps diff --git a/spec/dummy/client/app/startup/ClientRenderedHtml.jsx b/spec/dummy/client/app/startup/ClientRenderedHtml.jsx new file mode 100644 index 000000000..ea32466a6 --- /dev/null +++ b/spec/dummy/client/app/startup/ClientRenderedHtml.jsx @@ -0,0 +1,17 @@ +// Top level component for simple client side only rendering +import React from 'react'; + +import EchoProps from '../components/EchoProps'; + +/* + * Export a function that takes the props and returns a ReactComponent. + * This is used for the client rendering hook after the page html is rendered. + * React will see that the state is the same and not do anything. + * Note, this is imported as "HelloWorldApp" by "clientRegistration.jsx" + * + * Note, this is a fictional example, as you'd only use a generator function if you wanted to run + * some extra code, such as setting up Redux and React-Router. + */ +export default (props, railsContext) => ( + +); diff --git a/spec/dummy/client/app/startup/ServerRenderedHtml.jsx b/spec/dummy/client/app/startup/ServerRenderedHtml.jsx new file mode 100644 index 000000000..4bb6f1a7b --- /dev/null +++ b/spec/dummy/client/app/startup/ServerRenderedHtml.jsx @@ -0,0 +1,21 @@ +// Top level component for simple client side only rendering +import React from 'react'; +import { renderToString } from 'react-dom/server' +import EchoProps from '../components/EchoProps'; + +/* + * Export a function that takes the props and returns an object with { renderedHtml } + * Note, this is imported as "RenderedHtml" by "serverRegistration.jsx" + * + * Note, this is a fictional example, as you'd only use a generator function if you wanted to run + * some extra code, such as setting up Redux and React-Router. + * + * And the use of renderToString would probably be done with react-router v4 + * + */ +export default (props, railsContext) => { + const renderedHtml = renderToString( + + ); + return { renderedHtml }; +}; diff --git a/spec/dummy/client/app/startup/clientRegistration.jsx b/spec/dummy/client/app/startup/clientRegistration.jsx index a72c78c83..4b4ad703d 100644 --- a/spec/dummy/client/app/startup/clientRegistration.jsx +++ b/spec/dummy/client/app/startup/clientRegistration.jsx @@ -16,6 +16,9 @@ import DeferredRenderApp from './DeferredRenderAppRenderer'; import SharedReduxStore from '../stores/SharedReduxStore'; +// Deferred render on the client side w/ server render +import RenderedHtml from './ClientRenderedHtml'; + ReactOnRails.setOptions({ traceTurbolinks: true, }); @@ -32,7 +35,8 @@ ReactOnRails.register({ CssModulesImagesFontsExample, ManualRenderApp, DeferredRenderApp, - CacheDisabled + CacheDisabled, + RenderedHtml, }); ReactOnRails.registerStore({ diff --git a/spec/dummy/client/app/startup/serverRegistration.jsx b/spec/dummy/client/app/startup/serverRegistration.jsx index d0db2f97c..5df515a62 100644 --- a/spec/dummy/client/app/startup/serverRegistration.jsx +++ b/spec/dummy/client/app/startup/serverRegistration.jsx @@ -29,6 +29,9 @@ import SharedReduxStore from '../stores/SharedReduxStore'; // Deferred render on the client side w/ server render import DeferredRenderApp from './DeferredRenderAppServer'; +// Deferred render on the client side w/ server render +import RenderedHtml from './ServerRenderedHtml'; + ReactOnRails.register({ HelloWorld, HelloWorldWithLogAndThrow, @@ -41,6 +44,7 @@ ReactOnRails.register({ PureComponent, CssModulesImagesFontsExample, DeferredRenderApp, + RenderedHtml, }); ReactOnRails.registerStore({ diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 689a4a9b6..63855424b 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -28,4 +28,5 @@ get "pure_component" => "pages#pure_component" 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" end diff --git a/spec/dummy/spec/features/integration_spec.rb b/spec/dummy/spec/features/integration_spec.rb index 87b1eb230..cb92cc1fb 100644 --- a/spec/dummy/spec/features/integration_spec.rb +++ b/spec/dummy/spec/features/integration_spec.rb @@ -176,6 +176,15 @@ def change_text_expect_dom_selector(dom_selector) end end +feature "renderedHtml from generator function", :js do + subject { page } + background { visit "/rendered_html" } + scenario "renderedHtml should not have any errors" do + expect(subject).to have_text "Props: {\"hello\":\"world\"}" + expect(subject.html).to include("[SERVER] RENDERED RenderedHtml to dom node with id") + end +end + shared_examples "React Component Shared Store" do |url| subject { page } background { visit url }