diff --git a/.gitignore b/.gitignore index 85ab422..8212c6c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ cypress/videos # IDE .idea *.sublime-workspace +.vscode diff --git a/package.json b/package.json index 04afff5..7b81349 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "eslint-plugin-jsx-a11y": "^3.0.2", "eslint-plugin-react": "^6.9.0", "jest": "^18.1.0", + "mobx-react-devtools": "^4.2.15", "react-addons-test-utils": "^15.4.2" }, "dependencies": { @@ -66,6 +67,8 @@ "file-loader": "^0.10.1", "helmet": "^3.4.0", "html-webpack-plugin": "^2.28.0", + "mobx": "^3.1.16", + "mobx-react": "^4.2.1", "morgan": "^1.7.0", "pretty-error": "^2.0.2", "react": "^15.4.2", @@ -74,6 +77,7 @@ "react-helmet": "^4.0.0", "rimraf": "^2.6.1", "rollbar": "^0.6.5", + "serialize-javascript": "^1.3.0", "source-map-support": "^0.4.9", "spdy": "^3.4.4", "url-loader": "^0.5.8", diff --git a/src/browser/App.react.js b/src/browser/App.react.js index 04b1924..12031bf 100644 --- a/src/browser/App.react.js +++ b/src/browser/App.react.js @@ -1,15 +1,44 @@ +import React, { Component, PropTypes as RPT } from 'react'; import Helmet from 'react-helmet'; -import React from 'react'; +import { observer, inject } from 'mobx-react'; import logo from '../../assets/images/haystack_logo.png'; -const App = () => ( -
- - -
-); +@inject('sample') +@observer +class App extends Component { + handlePlus = () => this.props.sample.plus() + handleMinus = () => this.props.sample.minus() + + render() { + const { sample } = this.props; + + return ( +
+ + + +

+ + + + +

{sample.count}

+ + +
+ ); + } +} + +App.wrappedComponent.propTypes = { + sample: RPT.shape({ + count: RPT.number.isRequired, + plus: RPT.func.isRequired, + minus: RPT.func.isRequired, + }).isRequired +}; export default App; diff --git a/src/browser/BrowserProvider.react.js b/src/browser/BrowserProvider.react.js index 1e02ada..9c7c967 100644 --- a/src/browser/BrowserProvider.react.js +++ b/src/browser/BrowserProvider.react.js @@ -1,3 +1,33 @@ -const BrowserProvider = ({ children }) => children; +import React, { PropTypes as RPT } from 'react'; +import { Provider } from 'mobx-react'; +import createStores from '../common/createStores'; + +const stores = createStores(window.MOBX_STATE); + +let AppRoot = null; + +// Use mobx-react-devtools in dev build +if (process.env.APP_ENV === 'development') { + const DevTools = require('mobx-react-devtools').default; // eslint-disable-line import/no-extraneous-dependencies, global-require + + AppRoot = children => ( +
+ {children} + +
+ ); +} else { + AppRoot = children => children; +} + +const BrowserProvider = ({ children }) => ( + + {AppRoot(children)} + +); + +BrowserProvider.propTypes = { + children: RPT.node.isRequired +}; export default BrowserProvider; diff --git a/src/browser/__tests__/App.react.spec.js b/src/browser/__tests__/App.react.spec.js index 2cc851d..73b2693 100644 --- a/src/browser/__tests__/App.react.spec.js +++ b/src/browser/__tests__/App.react.spec.js @@ -1,7 +1,11 @@ import { shallow } from 'enzyme'; import React from 'react'; import App from '../App.react'; +import createStores from '../../common/createStores'; + +const stores = createStores(); test('shallowly renders App', () => { - expect(shallow()).toMatchSnapshot(); + const options = { context: { mobxStores: { ...stores } } }; + expect(shallow(, options).shallow()).toMatchSnapshot(); }); diff --git a/src/browser/__tests__/__snapshots__/App.react.spec.js.snap b/src/browser/__tests__/__snapshots__/App.react.spec.js.snap index e096a2c..9d30286 100644 --- a/src/browser/__tests__/__snapshots__/App.react.spec.js.snap +++ b/src/browser/__tests__/__snapshots__/App.react.spec.js.snap @@ -9,5 +9,22 @@ exports[`test shallowly renders App 1`] = ` alt="logo" src="test-file-stub" /> +
+
+ + +

+ `; diff --git a/src/browser/__tests__/store.spec.js b/src/browser/__tests__/store.spec.js new file mode 100644 index 0000000..6ead9a4 --- /dev/null +++ b/src/browser/__tests__/store.spec.js @@ -0,0 +1,25 @@ +import SampleStore from '../store'; + +test('sample store actions', () => { + const store = new SampleStore(); + + expect(store.count).toBe(0); + + store.plus(); + expect(store.count).toBe(1); + store.plus(); + expect(store.count).toBe(2); + store.minus(); + expect(store.count).toBe(1); +}); + +test('sample store computed', () => { + const store = new SampleStore(); + + expect(store.special).toBe('¯\\_(ツ)_/¯'); + + store.count = 5; + expect(store.special).toBe('•_•)
( •_•)>⌐■-■
(⌐■_■)'); + store.count = 4; + expect(store.special).toBe('¯\\_(ツ)_/¯'); +}); diff --git a/src/browser/store.js b/src/browser/store.js new file mode 100644 index 0000000..53325ae --- /dev/null +++ b/src/browser/store.js @@ -0,0 +1,19 @@ +import { action, observable, computed } from 'mobx'; + +export default class SampleStore { + @observable count = 0; + + @action plus() { + this.count += 1; + } + + @action minus() { + this.count -= 1; + } + + @computed get special() { + return this.count < 5 + ? '¯\\_(ツ)_/¯' + : '•_•)
( •_•)>⌐■-■
(⌐■_■)'; + } +} diff --git a/src/common/__tests__/createStores.spec.js b/src/common/__tests__/createStores.spec.js new file mode 100644 index 0000000..fbb0c17 --- /dev/null +++ b/src/common/__tests__/createStores.spec.js @@ -0,0 +1,55 @@ +import { observable, toJS } from 'mobx'; +import createStores, { createNewStores, getState } from '../createStores'; +import SampleStore from '../../browser/store'; + +const store1 = observable({ foo: 'foo' }); +class Store2 { + @observable bar = 'foo_too'; +} + +const expectedStoreData = { + store1: { foo: 'foo' }, + store2: { bar: 'foo_too' }, +}; + +test('createStores getState', () => { + const actual = getState({ store1, store2: new Store2() }); + + expect(actual).toEqual(expectedStoreData); +}); + +test('createStores createNewStores', () => { + const initialStoreData = { + store1: { foo: 'foo' }, + store2: { bar: 'foo_too' }, + }; + const stores = { store1, store2: new Store2() }; + const actualStores = createNewStores(stores); + + expect(actualStores).toEqual(stores); + expect(toJS(actualStores.store1)).toEqual(initialStoreData.store1); + expect(toJS(actualStores.store2)).toEqual(initialStoreData.store2); + + const newData = { + store1: { foo: 'xyz' }, + store2: { bar: 'abc' }, + }; + const actualHydratedStores = createNewStores(stores, newData); + + expect(actualHydratedStores).toEqual(stores); + expect(toJS(actualHydratedStores.store1)).toEqual(newData.store1); + expect(toJS(actualHydratedStores.store2)).toEqual(newData.store2); +}); + +test('createStores', () => { + const stores = createStores(); + + expect(stores.sample).toBeInstanceOf(SampleStore); + expect(toJS(stores.sample)).toEqual({ count: 0 }); + + const initialData = { sample: { count: 55 } }; + const storesWData = createStores(initialData); + + expect(storesWData.sample).toBeInstanceOf(SampleStore); + expect(toJS(storesWData.sample)).toEqual({ count: 55 }); +}); diff --git a/src/common/createStores.js b/src/common/createStores.js new file mode 100644 index 0000000..c176cb7 --- /dev/null +++ b/src/common/createStores.js @@ -0,0 +1,42 @@ +import { toJS, extendObservable } from 'mobx'; +import SampleStore from '../browser/store'; + +const createAndHydrateStore = (store, initialData) => { + const maybeHydratedStore = initialData ? extendObservable(store, initialData) : store; + return maybeHydratedStore; +}; + +export const createNewStores = (stores, initialData = {}) => + Object.keys(stores).reduce( + (finalStores, storeKey) => ({ + ...finalStores, + [storeKey]: createAndHydrateStore(stores[storeKey], initialData[storeKey]), + }), + {} + ); + +export const getState = stores => + Object.keys(stores).reduce( + (data, storeKey) => ({ + ...data, + [storeKey]: toJS(stores[storeKey]), + }), + {} + ); + +const createStores = (initialData = {}) => { + /** + * Define the stores + * e.g { + * todos: new Todos(), + * user + * } + */ + const stores = { + sample: new SampleStore(), + }; + + return createNewStores(stores, initialData); +}; + +export default createStores; diff --git a/src/server/frontend/Html.react.js b/src/server/frontend/Html.react.js index 5cafaac..121fe4e 100644 --- a/src/server/frontend/Html.react.js +++ b/src/server/frontend/Html.react.js @@ -1,10 +1,11 @@ /* eslint-disable react/no-danger */ import React, { PropTypes as RPT } from 'react'; +import serialize from 'serialize-javascript'; import Rollbar from './scripts/Rollbar'; import Script from './Script.react'; import { googleTagManagerNoScript, googleTagManagerScript } from './scripts/GoogleTagManager'; -const Html = ({ bodyHtml, javascripts = {}, helmet, options }) => ( +const Html = ({ bodyHtml, javascripts = {}, helmet, options, mobxState }) => ( {googleTagManagerScript()} @@ -24,6 +25,7 @@ const Html = ({ bodyHtml, javascripts = {}, helmet, options }) => ( {googleTagManagerNoScript()}

"`; +exports[`test render 1`] = `"
"`; diff --git a/src/server/frontend/render.js b/src/server/frontend/render.js index 95231d0..2fc9a24 100644 --- a/src/server/frontend/render.js +++ b/src/server/frontend/render.js @@ -1,11 +1,18 @@ import Helmet from 'react-helmet'; import React from 'react'; import ReactDOMServer from 'react-dom/server'; +import mobxReact from 'mobx-react'; import Html from './Html.react'; import ServerProvider from './ServerProvider.react'; +import createStores, { getState } from '../../common/createStores'; + +// https://github.com/mobxjs/mobx-react#server-side-rendering-with-usestaticrendering +mobxReact.useStaticRendering(true); export default function render(app, options = {}) { - const appHtml = ReactDOMServer.renderToString({app}); + const mobxStores = createStores({ sample: { count: 2 } }); + + const appHtml = ReactDOMServer.renderToString({app}); const { javascript: javascripts } = webpackIsomorphicTools.assets(); @@ -15,6 +22,7 @@ export default function render(app, options = {}) { options={options} helmet={Helmet.rewind()} bodyHtml={`
${appHtml}
`} + mobxState={getState(mobxStores)} /> ); return `${docHtml}`; diff --git a/yarn.lock b/yarn.lock index 4f8a85d..d8610c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2482,6 +2482,10 @@ hoek@2.x.x: version "2.16.3" resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" +hoist-non-react-statics@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" + home-or-tmp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" @@ -3549,6 +3553,20 @@ minimist@^1.1.1, minimist@^1.2.0: dependencies: minimist "0.0.8" +mobx-react-devtools@^4.2.15: + version "4.2.15" + resolved "https://registry.yarnpkg.com/mobx-react-devtools/-/mobx-react-devtools-4.2.15.tgz#881c038fb83db4dffd1e72bbaf5374d26b2fdebb" + +mobx-react@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-4.2.1.tgz#35324834b71ae27a7553d76ad413e5f96ba40db5" + dependencies: + hoist-non-react-statics "^1.2.0" + +mobx@^3.1.16: + version "3.1.16" + resolved "https://registry.yarnpkg.com/mobx/-/mobx-3.1.16.tgz#7ef06b6de7cba31d2ca872484d47ee36265d7768" + morgan@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.7.0.tgz#eb10ca8e50d1abe0f8d3dad5c0201d052d981c62" @@ -4437,6 +4455,10 @@ send@0.14.2: range-parser "~1.2.0" statuses "~1.3.1" +serialize-javascript@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.3.0.tgz#86a4f3752f5c7e47295449b0bbb63d64ba533f05" + serve-static@~1.11.2: version "1.11.2" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.11.2.tgz#2cf9889bd4435a320cc36895c9aa57bd662e6ac7"