diff --git a/.babelrc b/.babelrc index 78de745..acc67d1 100644 --- a/.babelrc +++ b/.babelrc @@ -3,13 +3,6 @@ "es2015", "react" ], - "compact": true, - "env": { - "hmr": { - "presets": [ - "react-hmre" - ] - } - } + "compact": true } diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 0000000..d00a128 --- /dev/null +++ b/.flowconfig @@ -0,0 +1,3 @@ +[ignore] +.*/node_modules/react/node_modules/.* + diff --git a/package.json b/package.json index 9072613..937a652 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "start": "node build/server/index.js", "start:dev": "NODE_ENV=development node_modules/nodemon/bin/nodemon.js build/server/index.js", "start:hot": "HMR=true npm run start:dev", - "hot": "NODE_ENV=development BABEL_ENV=hmr node webpack-dev-server.js" + "hot": "NODE_ENV=development node webpack-dev-server.js", + "flow": "flow" }, "license": "MIT", "author": { @@ -37,6 +38,7 @@ "webpack" ], "devDependencies": { + "flow-bin": "^0.22.1", "nodemon": "^1.8.1", "webpack-dev-server": "^1.14.1" }, @@ -46,12 +48,12 @@ "babel-loader": "^6.2.1", "babel-preset-es2015": "^6.3.13", "babel-preset-react": "^6.3.13", - "babel-preset-react-hmre": "^1.0.1", "css-loader": "^0.23.1", + "deep-freeze": "^0.0.1", "express": "^4.13.3", "extract-text-webpack-plugin": "^1.0.1", "jade": "^1.11.0", - "lodash": "^3.10.1", + "lodash": "^4.6.1", "null-loader": "^0.1.1", "postcss-import": "^8.0.2", "postcss-loader": "^0.8.0", @@ -60,6 +62,7 @@ "react-dom": "^0.14.7", "react-router": "^2.0.0-rc4", "rimraf": "^2.5.1", + "sculpt": "git@github.com:malectro/sculpt", "serialize-javascript": "^1.1.2", "serve-favicon": "^2.3.0", "source-map-support": "^0.4.0", diff --git a/src/client.js b/src/client.js index 5aac124..fea4ab1 100644 --- a/src/client.js +++ b/src/client.js @@ -1,28 +1,59 @@ import React from 'react'; -import { render } from 'react-dom'; -import { Router, browserHistory, match } from 'react-router'; +import ReactDOM, {render} from 'react-dom'; +import {Router, browserHistory, match} from 'react-router'; import IndexStore from 'src/stores/index'; import routes from 'src/routes'; -import { getDependencies } from 'src/utils/index'; -import { FluxContext } from 'src/utils/wrappers'; +import {getDependencies} from 'src/utils/index'; +import FluxRoot from 'src/flux/root.jsx'; -const store = new IndexStore(); +let store = new IndexStore(); +store.initialize(data); -store.initialize(window.data); +let currentRoutes = routes; // Get route dependencies whenever a route component is rendered. const routeHandler = (Component, props) => { getDependencies([props.route], store, props.params); - return + return ; +}; + +function renderAll() { + match({currentRoutes, location}, () => { + render(( + + + + ), document.getElementById('app')); + }); } -match({routes, location }, () => { - render(( - - - - ), document.getElementById('app')); -}) +renderAll(); + + +if (module.hot) { + module.hot.accept('src/routes', () => { + // NOTE (kyle): i'm not sure if this is sound, but we'll never run HMR on prod + currentRoutes = require('src/routes').default; + ReactDOM.unmountComponentAtNode(document.getElementById('app')); + store.cache.setPerma(true); + renderAll(); + store.cache.setPerma(false); + }); + + module.hot.accept('src/stores/index', () => { + const NewIndexStore = require('src/stores/index').default; + const data = store.serialize(); + + store = new NewIndexStore(); + store.initialize(data); + + ReactDOM.unmountComponentAtNode(document.getElementById('app')); + store.cache.setPerma(true); + renderAll(); + store.cache.setPerma(false); + }); +} +window.store = store; diff --git a/src/components/app.jsx b/src/components/app.jsx index 049614b..7328068 100644 --- a/src/components/app.jsx +++ b/src/components/app.jsx @@ -10,7 +10,7 @@ export default class App extends React.Component { return (
- Hi world! + Hello world!! About Dashboard Things diff --git a/src/components/list/list.jsx b/src/components/list/list.jsx index 3895329..f434636 100644 --- a/src/components/list/list.jsx +++ b/src/components/list/list.jsx @@ -3,12 +3,12 @@ import styles from './list.css'; import React from 'react'; import { Link } from 'react-router'; -import { subscribeToStore } from 'src/utils/wrappers'; +import FluxComponent from 'src/flux/component.jsx'; class List extends React.Component { render() { - const things = this.context.store.stores.ids.getState() || []; + const things = this.props.store.stores.ids.getOddThings() || []; return (
{ things.map(thing => ( @@ -23,5 +23,5 @@ class List extends React.Component { } -export default subscribeToStore(List); +export default FluxComponent(List); diff --git a/src/components/thing/thing.jsx b/src/components/thing/thing.jsx index 451255c..c4bcb05 100644 --- a/src/components/thing/thing.jsx +++ b/src/components/thing/thing.jsx @@ -2,22 +2,22 @@ import styles from './thing.css'; import React from 'react'; -import { subscribeToStore } from 'src/utils/wrappers'; +import FluxComponent from 'src/flux/component.jsx'; class Thing extends React.Component { render() { const id = parseInt(this.props.params.id, 10); - const things = this.context.store.stores.things.getState(); + const things = this.props.store.stores.things.getState(); const thing = things && things[id] || {}; return (
- Thing { thing.id } : { thing.text } + Things { thing.id } : { thing.text }
); } } -export default subscribeToStore(Thing); +export default FluxComponent(Thing); diff --git a/src/flux/component.jsx b/src/flux/component.jsx new file mode 100644 index 0000000..1c6b93b --- /dev/null +++ b/src/flux/component.jsx @@ -0,0 +1,24 @@ +/* @flow */ + +import React from 'react'; + +import typeof Store from 'stores/index'; + + +export default function(Component: Object): Function { + const FluxComponent = (props, context) => { + const { store } = context; + const fluxProps: { store: Store; ref?: Function } = { + store, + }; + if (props.fluxRef) { + fluxProps.ref = props.fluxRef; + } + return ; + } + FluxComponent.contextTypes = { + store: React.PropTypes.object, + }; + return FluxComponent; +}; + diff --git a/src/flux/root.jsx b/src/flux/root.jsx new file mode 100644 index 0000000..1892235 --- /dev/null +++ b/src/flux/root.jsx @@ -0,0 +1,45 @@ +import React from 'react'; + + +/** + * Top-level component that provides flux objects via context to all children. + * + * Should be used as a wrapper around any routing to provide the flux objects + * to the React subtree. + * + * Based on Redux's Provider component: + * https://github.com/rackt/react-redux/blob/master/src/components/Provider.js + * + * NOTE: Only supplies the store in this implementation but could be updated + * to provide other flux-related objects if necessary. + */ +export default class FluxRoot extends React.Component { + constructor(props, context) { + super(props, context); + this.store = props.store; + } + + getChildContext() { + return { + store: this.store, + }; + } + + componentDidMount() { + this.listener = () => this.forceUpdate(); + this.store.on('update', this.listener); + } + + componentWillUnmount() { + this.store.removeListener('update', this.listener); + } + + render() { + return this.props.children; + } +} + +FluxRoot.childContextTypes = { + store: React.PropTypes.object, +}; + diff --git a/src/routes.js b/src/routes.js index 53c6d0c..08d68ce 100644 --- a/src/routes.js +++ b/src/routes.js @@ -4,6 +4,10 @@ import { Route, IndexRoute } from 'react-router'; import App from 'src/components/app.jsx'; import Dashboard from 'src/components/dashboard/dashboard.jsx'; +import About from 'src/components/about/about.jsx'; +import Things from 'src/components/things/things.jsx'; +import ListOfThings from 'src/components/list/list.jsx'; +import Thing from 'src/components/thing/thing.jsx'; import * as actions from 'src/actions/index'; @@ -11,56 +15,13 @@ import * as actions from 'src/actions/index'; if (typeof require.ensure !== 'function') require.ensure = (d, c) => c(require) export default ( - { - require.ensure([], (require) => { - cb(null, [ - { - path: 'about', - getComponent(location, cb) { - require.ensure([], () => { - cb(null, require('src/components/about/about.jsx').default); - }); - } - }, - { - path: 'list', - getComponent(location, cb) { - require.ensure([], () => { - cb(null, require('src/components/things/things.jsx').default); - }); - }, - dependencies: actions.getIds, - getChildRoutes(location, cb) { - require.ensure([], (require) => { - cb(null, [ - { - path: 'thing/:id', - getComponent(location, cb) { - require.ensure([], () => { - cb(null, require('src/components/thing/thing.jsx').default); - }); - }, - dependencies: actions.getThing, - } - ]); - }); - }, - getIndexRoute(location, cb) { - require.ensure([], (require) => { - cb(null, { - getComponent(location, cb) { - require.ensure([], () => { - cb(null, require('src/components/list/list.jsx').default); - }); - } - }); - }); - } - } - ]); - }); - }}> + + + + + + ); diff --git a/src/server.js b/src/server.js index 25dc2d1..d39504a 100644 --- a/src/server.js +++ b/src/server.js @@ -10,7 +10,7 @@ import routes from './routes'; import IndexStore from './stores/index'; import { getDependencies } from './utils/index'; -import { FluxContext } from './utils/wrappers'; +import FluxRoot from './flux/root.jsx'; const DEVELOPMENT = process.env.NODE_ENV === 'development'; @@ -57,9 +57,9 @@ app.get('/*', function(req, res) { Promise.all(dependencies) .then(() => { const content = renderToString(( - + - + )); const data = serialize(store.serialize()); res.render('index', { @@ -69,8 +69,8 @@ app.get('/*', function(req, res) { base: HOT_MODULE_REPLACEMENT ? 'http://localhost:8080' : '', }); }) - .catch((error) => { - console.log(error); + .catch(error => { + console.error(error.stack || error); res.status(404).send('Not found'); }); } else { diff --git a/src/stores/base.js b/src/stores/base.js index 59b108b..aee1fe7 100644 --- a/src/stores/base.js +++ b/src/stores/base.js @@ -1,6 +1,7 @@ import _ from 'lodash'; +import deepFreeze from 'deep-freeze'; import EventEmitter from 'events'; -import update from 'react-addons-update'; +import sculpt from 'sculpt'; export default class Store extends EventEmitter { @@ -8,18 +9,29 @@ export default class Store extends EventEmitter { super(); this.key = key; this.state = null; + this._selections = new WeakMap(); } initialize(data) { - this.state = data[this.key]; + const state = data[this.key]; + this.state = state ? deepFreeze(state) : state; + this._selections = new WeakMap(); } getState() { - return _.cloneDeep(this.state); + return this.state; } setState(data) { - this.state = this.state ? update(this.state, {$merge: data}) : data; + // TODO (kyle): i'm not sure $merg'ing here is expected behavior + this.state = this.state ? sculpt(this.state, {$assign: data}) : deepFreeze(data); + this._selections = new WeakMap(); + this.emit('update'); + } + + updateState(spec) { + this.state = sculpt(this.state, spec); + this._selections = new WeakMap(); this.emit('update'); } @@ -28,5 +40,15 @@ export default class Store extends EventEmitter { [this.key]: this.getState(), }; } + + createSelector(method) { + return function () { + const result = this._selections[method]; + if (result) { + return result; + } + return this._selections[method] = method.apply(this, arguments); + } + } } diff --git a/src/stores/cache.js b/src/stores/cache.js index 623e206..e5edb03 100644 --- a/src/stores/cache.js +++ b/src/stores/cache.js @@ -1,4 +1,5 @@ import _ from 'lodash'; +import {set} from 'sculpt'; export default class Cache { @@ -11,10 +12,14 @@ export default class Cache { } set(key) { - this.cache[key] = new Date().getTime(); + this.cache = set(this.cache, key, new Date().getTime()); } expired(key, ttl) { + if (this.__perma) { + return false; + } + const cached = this.cache[key]; if (!cached) { return true; @@ -22,7 +27,7 @@ export default class Cache { const now = new Date().getTime(); if (now - cached > ttl) { - delete this.cache[key]; + this.cache = _.omit(this.cache, key); return true; } @@ -32,5 +37,9 @@ export default class Cache { serialize() { return this.cache; } + + setPerma(perma) { + this.__perma = perma; + } } diff --git a/src/stores/ids.js b/src/stores/ids.js index 05142f2..75a153c 100644 --- a/src/stores/ids.js +++ b/src/stores/ids.js @@ -1,8 +1,15 @@ +import _ from 'lodash'; import Store from './base'; export default class IdStore extends Store { constructor() { super('ids'); + + this.getOddThings = this.createSelector(this.getOddThings); + } + + getOddThings() { + return this.state.filter(id => id % 2); } } diff --git a/src/stores/index.js b/src/stores/index.js index f9b72fc..c8a4818 100644 --- a/src/stores/index.js +++ b/src/stores/index.js @@ -1,6 +1,6 @@ import _ from 'lodash'; import EventEmitter from 'events'; -import update from 'react-addons-update'; +import sculpt from 'sculpt'; import IdStore from './ids'; import ThingStore from './things'; @@ -35,12 +35,16 @@ class IndexStore extends EventEmitter { let data = _.reduce(this.stores, (state, store) => { const storeState = store.serialize(); if (storeState) { - state = update(state, {$merge: storeState}); + state = sculpt(state, {$assign: storeState}); } return state; }, {}); - data._cache = this.cache.serialize(); + data = sculpt(data, { + _cache: { + $set: this.cache.serialize(), + }, + }); return data; } diff --git a/src/utils/wrappers.js b/src/utils/wrappers.js deleted file mode 100644 index fab1757..0000000 --- a/src/utils/wrappers.js +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react'; - - -// Note: This module is used to create higher order components as an -// alternative to using mixins, based on the pattern here: -// https://medium.com/@dan_abramov/ -// mixins-are-dead-long-live-higher-order-components-94a0d2f9e750 - - -/** - * Re-render the given Component whenever the store updates and add the - * store to the Component's context (e.g. `this.context.store`). - * - * @param {Component} Component to re-render on store updates. - * - * @return {Component} Higher order component that will re-render on store - * updates. - */ -export function subscribeToStore(Component) { - // Add store to component's context (via `this.context.store`). - Component.contextTypes = { - store: React.PropTypes.object, - }; - - return React.createClass({ - contextTypes: { - store: React.PropTypes.object, - }, - - componentDidMount() { - this.context.store.on('update', this.rerender); - }, - - componentWillUnmount() { - this.context.store.removeListener('update', this.rerender); - }, - - rerender() { - this.forceUpdate(); - }, - - render() { - return ; - } - }); -}; - - -/** - * Top-level component that provides flux objects via context to all children. - * - * Should be used as a wrapper around any routing to provide the flux objects - * to the React subtree. - * - * Based on Redux's Provider component: - * https://github.com/rackt/react-redux/blob/master/src/components/Provider.js - * - * NOTE: Only supplies the store in this implementation but could be updated - * to provide other flux-related objects if necessary. - */ -export class FluxContext extends React.Component { - getChildContext() { - return { store: this.store }; - } - - constructor(props, context) { - super(props, context); - this.store = props.store; - } - - render() { - return this.props.children; - } -} -FluxContext.childContextTypes = { - store: React.PropTypes.object, -} -