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()}
+
{!options.disableJS && javascripts.vendor && }
@@ -47,7 +49,10 @@ Html.propTypes = {
}),
options: RPT.shape({
disableJS: RPT.bool
- })
+ }),
+ mobxState: RPT.shape({
+ sample: RPT.object
+ }).isRequired
};
Html.defaultProps = {
diff --git a/src/server/frontend/ServerProvider.react.js b/src/server/frontend/ServerProvider.react.js
index e89f0b2..4b97a0c 100644
--- a/src/server/frontend/ServerProvider.react.js
+++ b/src/server/frontend/ServerProvider.react.js
@@ -1,3 +1,21 @@
-const ServerProvider = ({ children }) => children;
+import React, { PropTypes as RPT } from 'react';
+import { Provider } from 'mobx-react';
+
+const ServerProvider = ({ children, stores }) => (
+
+ {children}
+
+);
+
+ServerProvider.propTypes = {
+ children: RPT.node.isRequired,
+ stores: RPT.shape({
+ sample: RPT.shape({
+ count: RPT.number.isRequired,
+ plus: RPT.func.isRequired,
+ minus: RPT.func.isRequired,
+ }).isRequired
+ }).isRequired
+};
export default ServerProvider;
diff --git a/src/server/frontend/__tests__/Html.react.spec.js b/src/server/frontend/__tests__/Html.react.spec.js
index 0fc8afc..d1bf921 100644
--- a/src/server/frontend/__tests__/Html.react.spec.js
+++ b/src/server/frontend/__tests__/Html.react.spec.js
@@ -4,7 +4,8 @@ import Html from '../Html.react';
const props = {
bodyHtml: '',
- javascripts: { app: 'app.xxxx.js' }
+ javascripts: { app: 'app.xxxx.js' },
+ mobxState: { sample: { count: 0 } }
};
test('shallowly renders Html', () => {
diff --git a/src/server/frontend/__tests__/__snapshots__/Html.react.spec.js.snap b/src/server/frontend/__tests__/__snapshots__/Html.react.spec.js.snap
index a911f36..f6456fb 100644
--- a/src/server/frontend/__tests__/__snapshots__/Html.react.spec.js.snap
+++ b/src/server/frontend/__tests__/__snapshots__/Html.react.spec.js.snap
@@ -20,6 +20,13 @@ exports[`test shallowly renders Html 1`] = `
}
}
type="text/javascript" />
+
"`;
+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"