From 55ed923782345729db7f385ecdf69eed76792616 Mon Sep 17 00:00:00 2001 From: Joseph Tibbertsma Date: Wed, 30 Nov 2016 14:52:07 -0800 Subject: [PATCH] Modify register API to accept renderer functions (#581) Made the following changes to the node package: * ComponentRegistry.js: Modified register to detect generator functions with three arguments. Set the isRenderer key to true for these functions. * clientStartup.js: Added logic to delegate to renderer functions. * createReactElement.js: Now accepts an object instead of a component name. * serverRenderReactComponent.js: Throws an error if attempting to render a renderer function. * ReactOnRails.js: Change render function to call createReactElement with the component object. Doc changes: * README.md: Added section about renderer function under the section on generator functions. Moved the section on generator functions from the 'ReactOnRails View Helpers API' section to the 'Globally Exposing Your React Components' section. * Added a file code-splitting.md that describes how to use renderer functions to do code splitting with server rendering. Tests: * ComponentRegistry.test.js: Modified existing test cases to expect the isRenderer key to be false. Added a few test cases related to renderer functions. * serverRenderReactComponent.test.js: Show that an error gets thrown if trying to server render with a renderer function. * spec/dummy: Added two examples using rendering functions, one of which implements code splitting. Added three test to integration_spec.rb. Resolves: #477 --- CHANGELOG.md | 7 +- README.md | 17 +- docs/additional-reading/code-splitting.md | 155 ++++++++++++++++++ node_package/src/ComponentRegistry.js | 4 +- node_package/src/ReactOnRails.js | 5 +- node_package/src/clientStartup.js | 28 +++- node_package/src/createReactElement.js | 12 +- .../src/serverRenderReactComponent.js | 18 +- node_package/tests/Authenticity.test.js | 2 +- node_package/tests/ComponentRegistry.test.js | 50 ++++-- .../tests/serverRenderReactComponent.test.js | 16 ++ spec/dummy/app/views/pages/_header.erb | 6 + .../pages/client_side_manual_render.html.erb | 52 ++++++ ...rred_render_with_server_rendering.html.erb | 39 +++++ .../client/app/components/DeferredRender.jsx | 24 +++ .../components/DeferredRenderAsyncPage.jsx | 13 ++ .../app/startup/DeferredRenderAppRenderer.jsx | 39 +++++ .../app/startup/DeferredRenderAppServer.jsx | 38 +++++ .../app/startup/ManualRenderAppRenderer.jsx | 13 ++ .../client/app/startup/clientRegistration.jsx | 4 + .../client/app/startup/serverRegistration.jsx | 4 + .../webpack.client.rails.build.config.js | 1 + spec/dummy/config/routes.rb | 3 + spec/dummy/spec/features/integration_spec.rb | 33 ++++ 24 files changed, 548 insertions(+), 35 deletions(-) create mode 100644 docs/additional-reading/code-splitting.md create mode 100644 spec/dummy/app/views/pages/client_side_manual_render.html.erb create mode 100644 spec/dummy/app/views/pages/deferred_render_with_server_rendering.html.erb create mode 100644 spec/dummy/client/app/components/DeferredRender.jsx create mode 100644 spec/dummy/client/app/components/DeferredRenderAsyncPage.jsx create mode 100644 spec/dummy/client/app/startup/DeferredRenderAppRenderer.jsx create mode 100644 spec/dummy/client/app/startup/DeferredRenderAppServer.jsx create mode 100644 spec/dummy/client/app/startup/ManualRenderAppRenderer.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 654149d51..03013aa16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Contributors: please follow the recommendations outlined at [keepachangelog.com] ## [Unreleased] +## [6.3.0] +##### Added +- Modify register API to allow registration of renderers, allowing a user to manually render their app to the DOM [#581](https://github.com/shakacode/react_on_rails/pull/581) by [jtibbertsma](https://github.com/jtibbertsma). + ## [6.2.1] - 2016-11-19 - Removed unnecesary passing of context in the HelloWorld Container example and basic generator. [#612](https://github.com/shakacode/react_on_rails/pull/612) by [justin808](https://github.com/justin808) @@ -383,7 +387,8 @@ Best done with Object destructing: ##### Fixed - Fix several generator related issues. -[Unreleased]: https://github.com/shakacode/react_on_rails/compare/6.2.1...master +[Unreleased]: https://github.com/shakacode/react_on_rails/compare/6.3.0...master +[6.3.0]: https://github.com/shakacode/react_on_rails/compare/6.2.1...6.3.0 [6.2.1]: https://github.com/shakacode/react_on_rails/compare/6.2.0...6.2.1 [6.2.0]: https://github.com/shakacode/react_on_rails/compare/6.1.2...6.2.0 [6.1.2]: https://github.com/shakacode/react_on_rails/compare/6.1.1...6.1.2 diff --git a/README.md b/README.md index 4192cf561..3f9d30969 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,14 @@ You may want different initialization for your server rendered components. For e If you do want different code to run, you'd setup a separate webpack compilation file and you'd specify a different, server side entry file. ex. 'serverHelloWorldApp.jsx'. Note, you might be initializing HelloWorld with version specialized for server rendering. +#### 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. + +#### 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). + +Renderer functions are not meant to be used on the server, since there's no DOM on the server. Instead, use a generator function. Attempting to server render with a renderer function will cause an error. + ## ReactOnRails View Helpers API Once the bundled files have been generated in your `app/assets/webpack` folder and you have exposed your components globally, you will want to run your code in your Rails views using the included helper method. @@ -327,7 +335,7 @@ react_component(component_name, html_options: {}) ``` -+ **component_name:** Can be a React component, created using a ES6 class, or `React.createClass`, or a generator function that returns a React component. ++ **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). + **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! @@ -365,9 +373,6 @@ Note, you don't need to separately initialize your redux store. However, it's re 1. You want to have multiple components that access the same store. 2. You want to place the props to hydrate the client side stores at the very end of your HTML so that the browser can render all earlier HTML first. This is particularly useful if your props will be large. -### 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. - ### server_render_js `server_render_js(js_expression, options = {})` @@ -439,7 +444,7 @@ See [ReactOnRails JavaScript API](docs/api/javascript-api.md). Rails has built-in protection for Cross-Site Request Forgery (CSRF), see [Rails Documentation](http://guides.rubyonrails.org/security.html#cross-site-request-forgery-csrf). To nicely utilize this feature in JavaScript requests, React on Rails is offerring two helpers that can be used as following for POST, PUT or DELETE requests: -``` +```js import ReactOnRails from 'react-on-rails'; // reads from DOM csrf token generated by Rails in <%= csrf_meta_tags %> @@ -456,6 +461,7 @@ If you are using [jquery-ujs](https://github.com/rails/jquery-ujs) for AJAX call 1. [React on Rails docs for react-router](docs/additional-reading/react-router.md) 1. Examples in [spec/dummy/app/views/react_router](spec/dummy/app/views/react_router) and follow to the JavaScript code in the [spec/dummy/client/app/startup/ServerRouterApp.jsx](spec/dummy/client/app/startup/ServerRouterApp.jsx). +1. [Code Splitting docs](docs/additional-reading/code-splitting.md) for information about how to set up code splitting for server rendered routes. ## Deployment * Version 6.0 puts the necessary precompile steps automatically in the rake precompile step. You can, however, disable this by setting certain values to nil in the [config/initializers/react_on_rails.rb](spec/dummy/config/initializers/react_on_rails.rb). @@ -487,6 +493,7 @@ Node.js can be used as the backend for server-side rendering instead of [execJS] + [Developing with the Webpack Dev Server](docs/additional-reading/webpack-dev-server.md) + [Node Server Rendering](docs/additional-reading/node-server-rendering.md) + [Server Rendering Tips](docs/additional-reading/server-rendering-tips.md) + + [Code Splitting](docs/additional-reading/code-splitting.md) + **Development** + [React on Rails Basic Installation Tutorial](docs/tutorial.md) ([live demo](https://hello-react-on-rails.herokuapp.com)) diff --git a/docs/additional-reading/code-splitting.md b/docs/additional-reading/code-splitting.md new file mode 100644 index 000000000..4b47c9da9 --- /dev/null +++ b/docs/additional-reading/code-splitting.md @@ -0,0 +1,155 @@ +# Code Splitting + +What is code splitting? From the webpack documentation: + +> For big web apps it’s not efficient to put all code into a single file, especially if some blocks of code are only required under some circumstances. Webpack has a feature to split your codebase into “chunks” which are loaded on demand. Some other bundlers call them “layers”, “rollups”, or “fragments”. This feature is called “code splitting”. + +## Server Rendering and Code Splitting + +Let's say you're requesting a page that needs to fetch a code chunk from the server before it's able to render. If you do all your rendering on the client side, you don't have to do anything special. However, if the page is rendered on the server, you'll find that React will spit out the following error: + +> Warning: React attempted to reuse markup in a container but the checksum was invalid. This generally means that you are using server rendering and the markup generated on the server was not what the client was expecting. React injected new markup to compensate which works but you have lost many of the benefits of server rendering. Instead, figure out why the markup being generated is different on the client or server: + +> (client) + +Different markup is generated on the client than on the server. Why does this happen? When you register a component or generator function with `ReactOnRails.register`, react on rails will render the component as soon as the page loads. However, react-router renders a comment while waiting for the code chunk to be fetched from the server. This means that react will tear all of the server rendered code out of the DOM, and then rerender it a moment later once the code chunk arrives from the server, defeating most of the purpose of server rendering. + +### The solution + +To prevent this, you have to wait until the code chunk is fetched before doing the initial render on the client side. To accomplish this, react on rails allows you to register a renderer. This works just like registering a generator function, except that the function you pass takes three arguments: `renderer(props, railsContext, domNodeId)`, and is responsible for calling `ReactDOM.render` to render the component to the DOM. React on rails will automatically detect when a generator function takes three arguments, and will not call `ReactDOM.render`, instead allowing you to control the initial render yourself. + +Here's an example of how you might use this in practice: + +#### page.html.erb +```erb +<%= react_component("NavigationApp", prerender: true) %> +<%= react_component("RouterApp", prerender: true) %> +<%= redux_store_hydration_data %> +``` + +#### clientRegistration.js +```js +import ReactOnRails from 'react-on-rails'; +import NavigationApp from './NavigationApp'; + +// Note that we're importing a different RouterApp that in serverRegistration.js +// Renderer functions should not be used on the server, because there is no DOM. +import RouterApp from './RouterAppRenderer'; +import applicationStore from '../store/applicationStore'; + +ReactOnRails.registerStore({applicationStore}); +ReactOnRails.register({ + NavigationApp, + RouterApp, +}); +``` + +#### serverRegistration.js +```js +import ReactOnRails from 'react-on-rails'; +import NavigationApp from './NavigationApp'; + +// Note that we're importing a different RouterApp that in clientRegistration.js +import RouterApp from './RouterAppServer'; +import applicationStore from '../store/applicationStore'; + +ReactOnRails.registerStore({applicationStore}); +ReactOnRails.register({ + NavigationApp, + RouterApp, +}); +``` +Note that you should not register a renderer on the server, since there won't be a domNodeId when we're server rendering. Note that the `RouterApp` imported by `serverRegistration.js` is from a different file. For an example of how to set up an app for server rendering, see the [react router docs](react-router.md). + +#### RouterAppRenderer.jsx +```jsx +import ReactOnRails from 'react-on-rails'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import Router from 'react-router/lib/Router'; +import match from 'react-router/lib/match'; +import browserHistory from 'react-router/lib/browserHistory'; +import { Provider } from 'react-redux'; + +import routes from '../routes/routes'; + + +const RouterAppRenderer = (props, railsContext, domNodeId) => { + const store = ReactOnRails.getStore('applicationStore'); + const history = browserHistory; + + match({ history, routes }, (error, redirectionLocation, renderProps) => { + if (error) { + throw error; + } + + const reactElement = ( + + + + ); + + ReactDOM.render(reactElement, document.getElementById(domNodeId)); + }); +}; + +export default RouterAppRenderer; +``` + +What's going on in this example is that we're putting the rendering code in the callback passed to `match`. The effect is that the client render doesn't happen until the code chunk gets fetched from the server, preventing the client/server checksum mismatch. + +The idea is that match from react-router is async; it fetches the component using the getComponent method that you provide with the route definition, and then passes the props to the callback that are needed to do the complete render. Then we do the first render inside of the callback, so that the first render is the same as the server render. + +The server render matches the deferred render because the server bundle is a single file, and so it doesn't need to wait for anything to be fetched. + +### Working Example + +There's an implemented example of code splitting in the `spec/dummy` folder of this repository. + +See: + +- [spec/dummy/client/app/startup/clientRegistration.jsx](../../spec/dummy/client/app/startup/clientRegistration.jsx) +- [spec/dummy/client/app/startup/serverRegistration.jsx](../../spec/dummy/client/app/startup/serverRegistration.jsx) +- [spec/dummy/client/app/startup/DeferredRenderAppRenderer.jsx](../../spec/dummy/client/app/startup/DeferredRenderAppRenderer.jsx) <-- Code splitting implemented here +- [spec/dummy/client/app/startup/DeferredRenderAppServer.jsx](../../spec/dummy/client/app/startup/DeferredRenderAppServer.jsx) +- [spec/dummy/client/app/components/DeferredRender.jsx](../../spec/dummy/client/app/components/DeferredRender.jsx) +- [spec/dummy/client/app/components/DeferredRenderAsyncPage.jsx](../../spec/dummy/client/app/components/DeferredRenderAsyncPage.jsx) + +### Caveats + +If you're going to try to do code splitting with server rendered routes, you'll probably need to use seperate route definitions for client and server to prevent code splitting from happening for the server bundle. The server bundle should be one file containing all the JavaScript code. This will require you to have seperate webpack configurations for client and server. + +The reason is we do server rendering with ExecJS, which is not capable of doing anything asynchronous. It would be impossible to asyncronously fetch a code chunk while server rendering. See [this issue](https://github.com/shakacode/react_on_rails/issues/477) for a discussion. + +Also, do not attempt to register a renderer on the server. Instead, register either a generator function or a component. If you register a renderer in the server bundle, you'll get an error when react on rails tries to server render the component. + +## How does Webpack know where to find my code chunks? + +Add the following to the output key of your webpack config: + +```js +config = { + output: { + publicPath: '/assets/', + } +}; +``` + +This causes Webpack to prepend the code chunk filename with `/assets/` in the request url. The react on rails sets up the webpack config to put webpack bundles in `app/assets/javascripts/webpack`, and modifies `config/initializers/assets.rb` so that rails detects the bundles. This means that when we prepend the request url with `/assets/`, rails will know what webpack is asking for. + +See [rails-assets.md](./rails-assets.md) to learn more about static assets. + +If you forget to set the public path, webpack will request the code chunk at `/{filename}`. This will cause the request to be handled by the Rails router, which will send back a 404 response, assuming that you don't have a catch-all route. In your javascript console, you'll get the following error: + +> GET http://localhost:3000/1.1-bundle.js + +You'll also see the following in your Rails development log: + +> Started GET "/1.1-bundle.js" for 127.0.0.1 at 2016-11-29 15:21:55 -0800 +> +> ActionController::RoutingError (No route matches [GET] "/1.1-bundle.js") + +It's worth mentioning that in Webpack v2, it's possible to register an error handler by calling `catch` on the promise returned by `System.import`, so if you want to do error handling, you should use v2. The [example](#working-example) in `spec/dummy` is currently using Webpack v1. diff --git a/node_package/src/ComponentRegistry.js b/node_package/src/ComponentRegistry.js index 2df80d515..4426d5691 100644 --- a/node_package/src/ComponentRegistry.js +++ b/node_package/src/ComponentRegistry.js @@ -1,5 +1,5 @@ // key = name used by react_on_rails -// value = { name, component, generatorFunction: boolean } +// value = { name, component, generatorFunction: boolean, isRenderer: boolean } import generatorFunction from './generatorFunction'; const registeredComponents = new Map(); @@ -20,11 +20,13 @@ export default { } const isGeneratorFunction = generatorFunction(component); + const isRenderer = isGeneratorFunction && component.length === 3; registeredComponents.set(name, { name, component, generatorFunction: isGeneratorFunction, + isRenderer, }); }); }, diff --git a/node_package/src/ReactOnRails.js b/node_package/src/ReactOnRails.js index f14cc7b2e..4dd72d487 100644 --- a/node_package/src/ReactOnRails.js +++ b/node_package/src/ReactOnRails.js @@ -148,7 +148,8 @@ ctx.ReactOnRails = { * @returns {virtualDomElement} Reference to your component's backing instance */ render(name, props, domNodeId) { - const reactElement = createReactElement({ name, props, domNodeId }); + const componentObj = ComponentRegistry.get(name); + const reactElement = createReactElement({ componentObj, props, domNodeId }); // eslint-disable-next-line react/no-render-return-value return ReactDOM.render(reactElement, document.getElementById(domNodeId)); @@ -157,7 +158,7 @@ ctx.ReactOnRails = { /** * Get the component that you registered * @param name - * @returns {name, component, generatorFunction} + * @returns {name, component, generatorFunction, isRenderer} */ getComponent(name) { return ComponentRegistry.get(name); diff --git a/node_package/src/clientStartup.js b/node_package/src/clientStartup.js index 4f72e2178..375b8ecdd 100644 --- a/node_package/src/clientStartup.js +++ b/node_package/src/clientStartup.js @@ -63,11 +63,30 @@ function turbolinksVersion5() { return (typeof Turbolinks.controller !== 'undefined'); } +function delegateToRenderer(componentObj, props, railsContext, domNodeId, trace) { + const { name, component, isRenderer } = componentObj; + + if (isRenderer) { + if (trace) { + console.log(`\ +DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, railsContext:`, + props, railsContext); + } + + component(props, railsContext, domNodeId); + return true; + } + + return false; +} + /** - * Used for client rendering by ReactOnRails + * Used for client rendering by ReactOnRails. Either calls ReactDOM.render or delegates + * to a renderer registered by the user. * @param el */ function render(el, railsContext) { + const context = findContext(); const name = el.getAttribute('data-component-name'); const domNodeId = el.getAttribute('data-dom-id'); const props = JSON.parse(el.getAttribute('data-props')); @@ -76,8 +95,13 @@ function render(el, railsContext) { try { const domNode = document.getElementById(domNodeId); if (domNode) { + const componentObj = context.ReactOnRails.getComponent(name); + if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) { + return; + } + const reactElementOrRouterResult = createReactElement({ - name, + componentObj, props, domNodeId, trace, diff --git a/node_package/src/createReactElement.js b/node_package/src/createReactElement.js index 39d06dcf5..4857c4cd0 100644 --- a/node_package/src/createReactElement.js +++ b/node_package/src/createReactElement.js @@ -2,13 +2,11 @@ import React from 'react'; -import ReactOnRails from './ReactOnRails'; - /** * Logic to either call the generatorFunction or call React.createElement to get the * React.Component * @param options - * @param options.name + * @param options.componentObj * @param options.props * @param options.domNodeId * @param options.trace @@ -16,12 +14,14 @@ import ReactOnRails from './ReactOnRails'; * @returns {Element} */ export default function createReactElement({ - name, + componentObj, props, railsContext, domNodeId, trace, }) { + const { name, component, generatorFunction } = componentObj; + if (trace) { if (railsContext && railsContext.serverSide) { console.log(`RENDERED ${name} to dom node with id: ${domNodeId} with railsContext:`, @@ -32,10 +32,6 @@ export default function createReactElement({ } } - const componentObj = ReactOnRails.getComponent(name); - - const { component, generatorFunction } = componentObj; - if (generatorFunction) { return component(props, railsContext); } diff --git a/node_package/src/serverRenderReactComponent.js b/node_package/src/serverRenderReactComponent.js index 7fd47b376..74d5d9f00 100644 --- a/node_package/src/serverRenderReactComponent.js +++ b/node_package/src/serverRenderReactComponent.js @@ -1,18 +1,32 @@ import ReactDOMServer from 'react-dom/server'; +import ComponentRegistry from './ComponentRegistry'; import createReactElement from './createReactElement'; import isRouterResult from './isRouterResult'; import buildConsoleReplay from './buildConsoleReplay'; import handleError from './handleError'; export default function serverRenderReactComponent(options) { - const { name, domNodeId, trace } = options; + const { name, domNodeId, trace, props, railsContext } = options; let htmlResult = ''; let hasErrors = false; try { - const reactElementOrRouterResult = createReactElement(options); + const componentObj = ComponentRegistry.get(name); + if (componentObj.isRenderer) { + throw new Error(`\ +Detected a renderer while server rendering component '${name}'. \ +See https://github.com/shakacode/react_on_rails#renderer-functions`); + } + + const reactElementOrRouterResult = createReactElement({ + componentObj, + domNodeId, + trace, + props, + railsContext, + }); if (isRouterResult(reactElementOrRouterResult)) { // We let the client side handle any redirect diff --git a/node_package/tests/Authenticity.test.js b/node_package/tests/Authenticity.test.js index 861955911..bac24308e 100644 --- a/node_package/tests/Authenticity.test.js +++ b/node_package/tests/Authenticity.test.js @@ -25,6 +25,6 @@ test('authenticityToken and authenticityHeaders', (assert) => { const realHeader = ReactOnRails.authenticityHeaders(); assert.deepEqual(realHeader, { 'X-CSRF-Token': testToken, 'X-Requested-With': 'XMLHttpRequest' }, - 'authenticityHeaders returns valid header with CFRS token', + 'authenticityHeaders returns valid header with CSRF token', ); }); diff --git a/node_package/tests/ComponentRegistry.test.js b/node_package/tests/ComponentRegistry.test.js index 2eb725d0c..d904a5e9d 100644 --- a/node_package/tests/ComponentRegistry.test.js +++ b/node_package/tests/ComponentRegistry.test.js @@ -2,6 +2,7 @@ /* eslint-disable react/prefer-es6-class */ /* eslint-disable react/prefer-stateless-function */ /* eslint-disable react/jsx-filename-extension */ +/* eslint-disable no-unused-vars */ import test from 'tape'; import React from 'react'; @@ -13,7 +14,7 @@ test('ComponentRegistry registers and retrieves generator function components', const C1 = () =>
HELLO
; ComponentRegistry.register({ C1 }); const actual = ComponentRegistry.get('C1'); - const expected = { name: 'C1', component: C1, generatorFunction: true }; + const expected = { name: 'C1', component: C1, generatorFunction: true, isRenderer: false }; assert.deepEqual(actual, expected, 'ComponentRegistry should store and retrieve a generator function'); }); @@ -27,7 +28,7 @@ test('ComponentRegistry registers and retrieves ES5 class components', (assert) }); ComponentRegistry.register({ C2 }); const actual = ComponentRegistry.get('C2'); - const expected = { name: 'C2', component: C2, generatorFunction: false }; + const expected = { name: 'C2', component: C2, generatorFunction: false, isRenderer: false }; assert.deepEqual(actual, expected, 'ComponentRegistry should store and retrieve a ES5 class'); }); @@ -43,27 +44,50 @@ test('ComponentRegistry registers and retrieves ES6 class components', (assert) } ComponentRegistry.register({ C3 }); const actual = ComponentRegistry.get('C3'); - const expected = { name: 'C3', component: C3, generatorFunction: false }; + const expected = { name: 'C3', component: C3, generatorFunction: false, isRenderer: false }; assert.deepEqual(actual, expected, 'ComponentRegistry should store and retrieve a ES6 class'); }); +test('ComponentRegistry registers and retrieves renderers', (assert) => { + assert.plan(1); + const C4 = (a1, a2, a3) => null; + ComponentRegistry.register({ C4 }); + const actual = ComponentRegistry.get('C4'); + const expected = { name: 'C4', component: C4, generatorFunction: true, isRenderer: true }; + assert.deepEqual(actual, expected, + 'ComponentRegistry registers and retrieves renderers'); +}); + /* * NOTE: Since ComponentRegistry is a singleton, it preserves value as the tests run. * Thus, tests are cummulative. */ test('ComponentRegistry registers and retrieves multiple components', (assert) => { assert.plan(3); - const C4 = () =>
WHY
; - const C5 = () =>
NOW
; - ComponentRegistry.register({ C4 }); + const C5 = () =>
WHY
; + const C6 = () =>
NOW
; ComponentRegistry.register({ C5 }); + ComponentRegistry.register({ C6 }); const components = ComponentRegistry.components(); - assert.equal(components.size, 5, 'size should be 5'); - assert.deepEqual(components.get('C4'), - { name: 'C4', component: C4, generatorFunction: true }); + assert.equal(components.size, 6, 'size should be 6'); assert.deepEqual(components.get('C5'), - { name: 'C5', component: C5, generatorFunction: true }); + { name: 'C5', component: C5, generatorFunction: true, isRenderer: false }); + assert.deepEqual(components.get('C6'), + { name: 'C6', component: C6, generatorFunction: true, isRenderer: false }); +}); + +test('ComponentRegistry only detects a renderer function if it has three arguments', (assert) => { + assert.plan(2); + const C7 = (a1, a2) => null; + const C8 = (a1) => null; + ComponentRegistry.register({ C7 }); + ComponentRegistry.register({ C8 }); + const components = ComponentRegistry.components(); + assert.deepEqual(components.get('C7'), + { name: 'C7', component: C7, generatorFunction: true, isRenderer: false }); + assert.deepEqual(components.get('C8'), + { name: 'C8', component: C8, generatorFunction: true, isRenderer: false }); }); test('ComponentRegistry throws error for retrieving unregistered component', (assert) => { @@ -76,9 +100,9 @@ test('ComponentRegistry throws error for retrieving unregistered component', (as test('ComponentRegistry throws error for setting null component', (assert) => { assert.plan(1); - const C6 = null; - assert.throws(() => ComponentRegistry.register({ C6 }), - /Called register with null component named C6/, + const C9 = null; + assert.throws(() => ComponentRegistry.register({ C9 }), + /Called register with null component named C9/, 'Expected an exception for calling ComponentRegistry.set with a null component.', ); }); diff --git a/node_package/tests/serverRenderReactComponent.test.js b/node_package/tests/serverRenderReactComponent.test.js index 2c9aebe44..86623f931 100644 --- a/node_package/tests/serverRenderReactComponent.test.js +++ b/node_package/tests/serverRenderReactComponent.test.js @@ -1,4 +1,5 @@ /* eslint-disable react/jsx-filename-extension */ +/* eslint-disable no-unused-vars */ import React from 'react'; import test from 'tape'; @@ -36,3 +37,18 @@ test('serverRenderReactComponent renders errors', (assert) => { assert.ok(okHtml, 'serverRenderReactComponent HTML should render error message XYZ'); 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; + ComponentStore.register({ X3 }); + + const { html } = + JSON.parse(serverRenderReactComponent({ name: 'X3', domNodeId: 'myDomId', trace: false })); + + const ok = html.indexOf('renderer') > 0 && html.indexOf('Exception in rendering!') > 0; + assert.ok( + ok, + 'serverRenderReactComponent renders an error if attempting to render a renderer', + ); +}); diff --git a/spec/dummy/app/views/pages/_header.erb b/spec/dummy/app/views/pages/_header.erb index d9eaf8a9b..e87f1a20e 100644 --- a/spec/dummy/app/views/pages/_header.erb +++ b/spec/dummy/app/views/pages/_header.erb @@ -56,6 +56,12 @@
  • <%= link_to "One Page with Many Examples at Once", root_path %>
  • +
  • + <%= link_to "Manually Rendered Component", client_side_manual_render_path %> +
  • +
  • + <%= link_to "Deferred Rendering of Async Route", deferred_render_path %> +
  • <%= link_to "React Router", react_router_path %>
  • diff --git a/spec/dummy/app/views/pages/client_side_manual_render.html.erb b/spec/dummy/app/views/pages/client_side_manual_render.html.erb new file mode 100644 index 000000000..2d103a599 --- /dev/null +++ b/spec/dummy/app/views/pages/client_side_manual_render.html.erb @@ -0,0 +1,52 @@ +<%= render "header" %> + +<%= react_component("ManualRenderApp", props: {}, prerender: false) %> +
    + +

    Using Renderer Functions to Manually Render Your App

    + + +

    + One possible use case for this is + Code Splitting. +

    diff --git a/spec/dummy/app/views/pages/deferred_render_with_server_rendering.html.erb b/spec/dummy/app/views/pages/deferred_render_with_server_rendering.html.erb new file mode 100644 index 000000000..f40298146 --- /dev/null +++ b/spec/dummy/app/views/pages/deferred_render_with_server_rendering.html.erb @@ -0,0 +1,39 @@ +<%= render "header" %> + +<%= react_component("DeferredRenderApp", props: {}, prerender: true) %> +
    + +

    Deferred Rendering of Async Routes

    + diff --git a/spec/dummy/client/app/components/DeferredRender.jsx b/spec/dummy/client/app/components/DeferredRender.jsx new file mode 100644 index 000000000..a002fdb02 --- /dev/null +++ b/spec/dummy/client/app/components/DeferredRender.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Link } from 'react-router'; + +const DeferredRender = ({ children }) => ( +
    +

    Deferred Rendering

    +

    + Here, we're testing async routes with server rendering. + By deferring the initial render, we can prevent a client/server + checksum mismatch error. +

    + { + children ? children : ( +

    + + Test Async Route + +

    + ) + } +
    +); + +export default DeferredRender; diff --git a/spec/dummy/client/app/components/DeferredRenderAsyncPage.jsx b/spec/dummy/client/app/components/DeferredRenderAsyncPage.jsx new file mode 100644 index 000000000..afcd597f9 --- /dev/null +++ b/spec/dummy/client/app/components/DeferredRenderAsyncPage.jsx @@ -0,0 +1,13 @@ +import React from 'react'; + +const DeferredRenderAsyncPage = () => ( +
    +

    Noice! It works.

    +

    + Now, try reloading this page and looking at the developer console. + There shouldn't be any client/server mismatch error from React. +

    +
    +); + +export default DeferredRenderAsyncPage; diff --git a/spec/dummy/client/app/startup/DeferredRenderAppRenderer.jsx b/spec/dummy/client/app/startup/DeferredRenderAppRenderer.jsx new file mode 100644 index 000000000..6f71ab263 --- /dev/null +++ b/spec/dummy/client/app/startup/DeferredRenderAppRenderer.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { match, Router, browserHistory } from 'react-router'; + +import DeferredRender from '../components/DeferredRender'; + +const DeferredRenderAppRenderer = (props, railsContext, domNodeId) => { + const history = browserHistory; + const routes = { + path: '/deferred_render_with_server_rendering', + component: DeferredRender, + childRoutes: [{ + path: '/deferred_render_with_server_rendering/async_page', + getComponent(nextState, callback) { + require.ensure([], (require) => { + const component = require('../components/DeferredRenderAsyncPage').default; + + // The first argument of the getComponent callback is error + callback(null, component); + }); + }, + }], + }; + + // This match is potentially asyncronous, because one of the routes + // implements an asyncronous getComponent. Since we do server rendering for this + // component, immediately rendering a Router could cause a client/server + // checksum mismatch. + match({ history, routes }, (error, redirectionLocation, routerProps) => { + if (error) { + throw error; + } + + const reactElement = ; + ReactDOM.render(reactElement, document.getElementById(domNodeId)); + }); +}; + +export default DeferredRenderAppRenderer; diff --git a/spec/dummy/client/app/startup/DeferredRenderAppServer.jsx b/spec/dummy/client/app/startup/DeferredRenderAppServer.jsx new file mode 100644 index 000000000..4695a84ef --- /dev/null +++ b/spec/dummy/client/app/startup/DeferredRenderAppServer.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { match, RouterContext } from 'react-router'; + +import DeferredRender from '../components/DeferredRender'; +import DeferredRenderAsyncPage from '../components/DeferredRenderAsyncPage'; + +const DeferredRenderAppServer = (props, railsContext) => { + let error; + let redirectLocation; + let routerProps; + + const { location } = railsContext; + const routes = { + path: '/deferred_render_with_server_rendering', + component: DeferredRender, + childRoutes: [{ + path: '/deferred_render_with_server_rendering/async_page', + component: DeferredRenderAsyncPage, + }], + }; + + // Unlike the match in DeferredRenderAppRenderer, this match is always + // syncronous because we directly require all the routes. Do not do anything + // asyncronous in code that will run on the server. + match({ location, routes }, (_error, _redirectLocation, _routerProps) => { + error = _error; + redirectLocation = _redirectLocation; + routerProps = _routerProps; + }); + + if (error || redirectLocation) { + return { error, redirectLocation }; + } + + return ; +}; + +export default DeferredRenderAppServer; diff --git a/spec/dummy/client/app/startup/ManualRenderAppRenderer.jsx b/spec/dummy/client/app/startup/ManualRenderAppRenderer.jsx new file mode 100644 index 000000000..0b08a6473 --- /dev/null +++ b/spec/dummy/client/app/startup/ManualRenderAppRenderer.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +export default (props, railsContext, domNodeId) => { + const reactElement = ( +
    +

    Manual Render Example

    +

    If you can see this, you can register renderer functions.

    +
    + ); + + ReactDOM.render(reactElement, document.getElementById(domNodeId)); +}; diff --git a/spec/dummy/client/app/startup/clientRegistration.jsx b/spec/dummy/client/app/startup/clientRegistration.jsx index 47e40e16c..e18ed41be 100644 --- a/spec/dummy/client/app/startup/clientRegistration.jsx +++ b/spec/dummy/client/app/startup/clientRegistration.jsx @@ -10,6 +10,8 @@ import ReduxSharedStoreApp from './ClientReduxSharedStoreApp'; import RouterApp from './ClientRouterApp'; import PureComponent from '../components/PureComponent'; import CssModulesImagesFontsExample from '../components/CssModulesImagesFontsExample'; +import ManualRenderApp from './ManualRenderAppRenderer'; +import DeferredRenderApp from './DeferredRenderAppRenderer'; import SharedReduxStore from '../stores/SharedReduxStore'; @@ -27,6 +29,8 @@ ReactOnRails.register({ RouterApp, PureComponent, CssModulesImagesFontsExample, + ManualRenderApp, + DeferredRenderApp, }); ReactOnRails.registerStore({ diff --git a/spec/dummy/client/app/startup/serverRegistration.jsx b/spec/dummy/client/app/startup/serverRegistration.jsx index a020579f5..d0db2f97c 100644 --- a/spec/dummy/client/app/startup/serverRegistration.jsx +++ b/spec/dummy/client/app/startup/serverRegistration.jsx @@ -26,6 +26,9 @@ import CssModulesImagesFontsExample from '../components/CssModulesImagesFontsExa import SharedReduxStore from '../stores/SharedReduxStore'; +// Deferred render on the client side w/ server render +import DeferredRenderApp from './DeferredRenderAppServer'; + ReactOnRails.register({ HelloWorld, HelloWorldWithLogAndThrow, @@ -37,6 +40,7 @@ ReactOnRails.register({ HelloString, PureComponent, CssModulesImagesFontsExample, + DeferredRenderApp, }); ReactOnRails.registerStore({ diff --git a/spec/dummy/client/webpack.client.rails.build.config.js b/spec/dummy/client/webpack.client.rails.build.config.js index 603464b1e..1c3a06ddf 100644 --- a/spec/dummy/client/webpack.client.rails.build.config.js +++ b/spec/dummy/client/webpack.client.rails.build.config.js @@ -12,6 +12,7 @@ const devBuild = process.env.NODE_ENV !== 'production'; config.output = { filename: '[name]-bundle.js', path: '../app/assets/webpack', + publicPath: '/assets/', }; // See webpack.client.base.config for adding modules common to both the webpack dev server and rails diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 8589d4313..910cdaaca 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -20,6 +20,9 @@ get "server_side_redux_app" => "pages#server_side_redux_app" get "server_side_hello_world_with_options" => "pages#server_side_hello_world_with_options" get "server_side_redux_app_cached" => "pages#server_side_redux_app_cached" + get "client_side_manual_render" => "pages#client_side_manual_render" + get "deferred_render_with_server_rendering(/*all)" => + "pages#deferred_render_with_server_rendering", as: :deferred_render get "render_js" => "pages#render_js" get "react_router(/*all)" => "react_router#index", as: :react_router get "pure_component" => "pages#pure_component" diff --git a/spec/dummy/spec/features/integration_spec.rb b/spec/dummy/spec/features/integration_spec.rb index 1465b180d..ba89efadc 100644 --- a/spec/dummy/spec/features/integration_spec.rb +++ b/spec/dummy/spec/features/integration_spec.rb @@ -143,6 +143,39 @@ def change_text_expect_dom_selector(dom_selector) end end +feature "Manual Rendering", :js do + subject { page } + background { visit "/client_side_manual_render" } + scenario "renderer function is called successfully" do + header_text = page.find(:css, "h1").text + expect(header_text).to eq("Manual Render Example") + expect(subject).to have_text "If you can see this, you can register renderer functions." + end +end + +feature "Code Splitting", :js do + subject { page } + background { visit "/deferred_render_with_server_rendering" } + scenario "clicking on async route causes async component to be fetched" do + header_text = page.find(:css, "h1").text + expect(header_text).to eq("Deferred Rendering") + expect(subject).to_not have_text "Noice!" + + click_link "Test Async Route" + expect(current_path).to eq("/deferred_render_with_server_rendering/async_page") + expect(subject).to have_text "Noice!" + end +end + +feature "Code Splitting w/ Server Rendering", :js do + subject { page } + background { visit "/deferred_render_with_server_rendering/async_page" } + scenario "loading an asyncronous route should not cause a client/server checksum mismatch" do + root = page.find(:xpath, "//div[@data-reactroot]") + expect(root["data-react-checksum"].present?).to be(true) + end +end + shared_examples "React Component Shared Store" do |url| subject { page } background { visit url }