diff --git a/modules/helpers/resolveAsyncState.js b/modules/helpers/resolveAsyncState.js new file mode 100644 index 0000000000..97b3d46511 --- /dev/null +++ b/modules/helpers/resolveAsyncState.js @@ -0,0 +1,24 @@ +var Promise = require('es6-promise').Promise; + +/** + * Resolves all values in the given stateDescription object + * and calls the setState function with new state as they resolve. + */ +function resolveAsyncState(stateDescription, setState) { + if (stateDescription == null) + return Promise.resolve({}); + + var keys = Object.keys(stateDescription); + + return Promise.all( + keys.map(function (key) { + return Promise.resolve(stateDescription[key]).then(function (value) { + var newState = {}; + newState[key] = value; + setState(newState); + }); + }) + ); +} + +module.exports = resolveAsyncState; diff --git a/modules/main.js b/modules/main.js index c840e54685..e5421c7371 100644 --- a/modules/main.js +++ b/modules/main.js @@ -7,6 +7,7 @@ exports.replaceWith = require('./helpers/replaceWith'); exports.transitionTo = require('./helpers/transitionTo'); exports.ActiveState = require('./mixins/ActiveState'); +exports.AsyncState = require('./mixins/AsyncState'); // Backwards compat with 0.1. We should // remove this when we ship 1.0. diff --git a/modules/mixins/AsyncState.js b/modules/mixins/AsyncState.js new file mode 100644 index 0000000000..748c246115 --- /dev/null +++ b/modules/mixins/AsyncState.js @@ -0,0 +1,106 @@ +var React = require('react'); +var resolveAsyncState = require('../helpers/resolveAsyncState'); + +/** + * A mixin for route handler component classes that fetch at least + * part of their state asynchronously. Classes that use it should + * declare a static `getInitialAsyncState` method that fetches state + * for a component after it mounts. This function is given three + * arguments: 1) the current route params, 2) the current query and + * 3) a function that can be used to set state as it is received. + * + * Example: + * + * var User = React.createClass({ + * + * statics: { + * + * getInitialAsyncState: function (params, query, setState) { + * // If you don't need to do anything async, just update + * // the state immediately and you're done. + * setState({ + * user: UserStore.getUserByID(params.userID) + * }); + * + * // Or, ignore the setState argument entirely and return a + * // hash with keys named after the state variables you want + * // to set. The values may be immediate values or promises. + * return { + * user: getUserByID(params.userID) // may be a promise + * }; + * + * // Or, stream your data! + * var buffer = ''; + * + * return { + * + * // Same as above, the stream state variable is set to the + * // value returned by this promise when it resolves. + * stream: getStreamingData(params.userID, function (chunk) { + * buffer += chunk; + * + * // Notify of progress. + * setState({ + * streamBuffer: buffer + * }); + * }) + * + * }; + * } + * + * }, + * + * getInitialState: function () { + * return { + * user: null, // Receives a value when getUserByID resolves. + * stream: null, // Receives a value when getStreamingData resolves. + * streamBuffer: '' // Used to track data as it loads. + * }; + * }, + * + * render: function () { + * if (!this.state.user) + * return ; + * + * return ( + *
+ *

Welcome {this.state.user.name}!

+ *

So far, you've received {this.state.streamBuffer.length} data!

+ *
+ * ); + * } + * + * }); + * + * When testing, use the `initialAsyncState` prop to simulate asynchronous + * data fetching. When this prop is present, no attempt is made to retrieve + * additional state via `getInitialAsyncState`. + */ +var AsyncState = { + + propTypes: { + initialAsyncState: React.PropTypes.object + }, + + getInitialState: function () { + return this.props.initialAsyncState || null; + }, + + updateAsyncState: function (state) { + if (this.isMounted()) + this.setState(state); + }, + + componentDidMount: function () { + if (this.props.initialAsyncState || !this.constructor.getInitialAsyncState) + return; + + resolveAsyncState( + this.constructor.getInitialAsyncState(this.props.params, this.props.query, this.updateAsyncState), + this.updateAsyncState + ); + } + +}; + +module.exports = AsyncState; diff --git a/specs/AsyncState.spec.js b/specs/AsyncState.spec.js new file mode 100644 index 0000000000..aded951996 --- /dev/null +++ b/specs/AsyncState.spec.js @@ -0,0 +1,47 @@ +require('./helper'); +var Promise = require('es6-promise').Promise; +var AsyncState = require('../modules/mixins/AsyncState'); + +describe('AsyncState', function () { + + + describe('a component that fetches part of its state asynchronously', function () { + it('resolves all state variables correctly', function (done) { + var User = React.createClass({ + mixins: [ AsyncState ], + statics: { + getInitialAsyncState: function (params, query, setState) { + setState({ + immediateValue: 'immediate' + }); + + setTimeout(function () { + setState({ + delayedValue: 'delayed' + }); + }); + + return { + promisedValue: Promise.resolve('promised') + }; + } + }, + render: function () { + return null; + } + }); + + var user = TestUtils.renderIntoDocument( + User() + ); + + setTimeout(function () { + expect(user.state.immediateValue).toEqual('immediate'); + expect(user.state.delayedValue).toEqual('delayed'); + expect(user.state.promisedValue).toEqual('promised'); + done(); + }, 20); + }); + }); + +}); diff --git a/specs/main.js b/specs/main.js index f84f98c5f9..8ae72db26d 100644 --- a/specs/main.js +++ b/specs/main.js @@ -1,6 +1,7 @@ // TODO: this is for webkpack-karma to only create one build instead of a build // for every spec file, there must be some sort of config but I can't find it ... require('./ActiveStore.spec.js'); +require('./AsyncState.spec.js'); require('./Path.spec.js'); require('./Route.spec.js'); require('./RouteStore.spec.js');