atom-react
Version:
An opiniated way to use ReactJS in a functional way in plain old Javascript, inspired by popular Clojurescript wrappers like Om
389 lines (331 loc) • 13.9 kB
JavaScript
'use strict';
var _ = require("lodash");
var React = require("react");
var ReactDOM = require("react-dom");
var Preconditions = require("./utils/preconditions");
var Atom = require("./atom/atom");
var AtomCursor = require("./atom/atomCursor");
var AtomUtils = require("./atom/atomUtils");
var AtomReactEvent = require("./atomReactEvent");
// For render the cursors are memoized
var AtomCursorMemoizedOption = {memoized: true};
var AtomReactContext = function AtomReactContext() {
this.stores = [];
this.actions = undefined;
this.reactContext = {};
this.memoizedReactContext = undefined;
this.eventListeners = [];
this.errorListeners = [];
this.verboseStateChangeLog = false;
this.lightStateChangeLog = false;
this.beforeRenderCallback = undefined;
this.beforeRenderCallback = undefined;
this.logPublishedEvents = false;
this.logTransactions = false;
this.atom = new Atom({
beforeTransactionCommit: this.beforeTransactionCommit.bind(this),
afterTransactionCommit: this.afterTransactionCommit.bind(this)
});
};
module.exports = AtomReactContext;
AtomReactContext.prototype.debugMode = function() {
this.setVerboseStateChangeLog(true);
this.setLightStateChangeLog(false);
this.setLogPublishedEvents(true);
this.setLogTransactions(true);
};
AtomReactContext.prototype.setVerboseStateChangeLog = function(bool) {
this.verboseStateChangeLog = bool;
};
AtomReactContext.prototype.setLightStateChangeLog = function(bool) {
this.lightStateChangeLog = bool;
};
AtomReactContext.prototype.setLogPublishedEvents = function(bool) {
this.logPublishedEvents = bool;
};
AtomReactContext.prototype.setLogTransactions = function(bool) {
this.logTransactions = bool;
};
AtomReactContext.prototype.setActions = function(actionsFactory) {
var publishFn = function publish(event) {
this.publishEvents(event);
}.bind(this);
var getContextFn = function getContext() {
return this.reactContext;
}.bind(this);
var getStateFn = function getState() {
return this.getState();
}.bind(this);
this.actions = actionsFactory({
publish: publishFn,
getContext: getContextFn,
getState: getStateFn
});
};
AtomReactContext.prototype.addStore = function(store) {
if ( !this.actions ) {
throw new Error("Before adding stores you must add the actions!");
}
if ( store.description.reactToChange ) {
console.warn("Store [",store.nameOrPath,"] should rather not implement 'reactToChange' because it will be removed in the future");
}
if ( store.description.init ) {
console.warn("Store [",store.nameOrPath,"] should rather not implement 'init' because it will be removed in the future");
}
this.stores.push({
store: store,
storeManager: store.createStoreManager(this)
});
};
AtomReactContext.prototype.getState = function() {
return this.atom.get();
};
AtomReactContext.prototype.setReactContext = function(context,forceFullUpdate) {
this.reactContext = context;
this.memoizedReactContext = undefined;
this.memoizedChildContextProviderFactory = undefined;
if ( forceFullUpdate ) {
// See https://github.com/facebook/react/issues/3298
setTimeout(function() {
this.unmount();
this.renderCurrentAtomState();
}.bind(this),0);
}
};
AtomReactContext.prototype.updateReactContext = function(updateFunction) {
var newContext = updateFunction(this.reactContext);
this.setReactContext(newContext,true);
};
AtomReactContext.prototype.unmount = function() {
ReactDOM.unmountComponentAtNode(this.mountConfig.domNode);
};
AtomReactContext.prototype.getMemoizedReactContextHolder = function(atomToRender) {
Preconditions.checkHasValue(atomToRender);
if ( !this.memoizedReactContext ) {
// TODO pass the AtomReact context (this) directly to react ! it will be more flexible
var libContext = {
atomReactContext: this,
// TODO the atomReactContext should be enough: remove the rest
atom: atomToRender,
publishEvent: this.publishEvent.bind(this),
publishEvents: this.publishEvents.bind(this),
addEventListener: this.addEventListener.bind(this),
removeEventListener: this.removeEventListener.bind(this)
};
this.memoizedReactContext = _.assign({},this.reactContext,libContext);
this.memoizedChildContextProviderFactory = ChildContextProviderFactory(this.memoizedReactContext);
console.debug("React context built: ",this.memoizedReactContext);
}
return {
context: this.memoizedReactContext,
childContextProviderFactory: this.memoizedChildContextProviderFactory
};
};
AtomReactContext.prototype.addEventListener = function(listener) {
this.eventListeners.push(listener);
};
AtomReactContext.prototype.removeEventListener = function(listener) {
var index = this.eventListeners.indexOf(listener);
if (index > -1) {
this.eventListeners.splice(index, 1);
} else {
throw new Error("listener not found");
}
};
AtomReactContext.prototype.addErrorListener = function(listener) {
this.errorListeners.push(listener);
};
AtomReactContext.prototype.removeErrorListener = function(listener) {
var index = this.errorListeners.indexOf(listener);
if (index > -1) {
this.errorListeners.splice(index, 1);
} else {
throw new Error("listener not found");
}
};
AtomReactContext.prototype.notifyErrorListeners = function(error,message) {
this.errorListeners.forEach(function (listener) {
try {
listener({error: error, message: message || "N/A"});
} catch (listenerError) {
console.error("notifyErrorListeners error", listenerError, error);
}
});
};
AtomReactContext.prototype.beforeRender = function(callback) {
this.beforeRenderCallback = callback;
};
AtomReactContext.prototype.afterRender = function(callback) {
this.afterRenderCallback = callback;
};
// TODO maybe accept both classes and factories in this method?
AtomReactContext.prototype.setMountConfig = function(reactClass,domNode) {
Preconditions.checkHasValue(reactClass,"reactClass is mandatory");
Preconditions.checkHasValue(domNode,"domNode is mandatory");
this.mountConfig = {
reactElementClass: reactClass,
reactElementFactory: React.createFactory(reactClass),
domNode: domNode
};
};
AtomReactContext.prototype.beforeTransactionCommit = function(newState,previousState) {
var shouldRender = (newState !== previousState);
if ( shouldRender ) {
if ( this.beforeRenderCallback ) this.beforeRenderCallback(this.atom.get());
this.renderCurrentAtomState();
}
};
AtomReactContext.prototype.afterTransactionCommit = function(newState,previousState,transactionData) {
var shouldRender = (newState !== previousState);
if ( shouldRender && this.logTransactions ) {
console.debug("Atom transaction commit",transactionData);
}
if ( shouldRender && this.afterRenderCallback ) this.afterRenderCallback(newState,previousState);
};
// Publish multiple events in the same transaction. Publishing order remains
AtomReactContext.prototype.publishEvents = function(arrayOrArguments) {
var eventArray = undefined;
if ( arrayOrArguments instanceof Array ) {
eventArray = arrayOrArguments;
} else {
eventArray = Array.prototype.slice.call(arguments, 0);
}
this.transact(function() {
eventArray.forEach(function(event) {
this.doPublishEvent(event);
}.bind(this));
}.bind(this));
};
AtomReactContext.prototype.publishEvent = function(event) {
this.transact(function() {
this.doPublishEvent(event);
}.bind(this));
};
AtomReactContext.prototype.doPublishEvent = function(event) {
if ( this.logPublishedEvents ) {
console.debug("Publishing event: %c"+event.name,"color: green;",event.data);
}
Preconditions.checkCondition(event instanceof AtomReactEvent,"Event fired is not an AtomReactEvent! " + event);
this.stores.forEach(function(store) {
try {
// TODO maybe stores should be regular event listeners?
store.storeManager.handleEvent(event);
} catch (error) {
var msg = "Store ["+store.store.nameOrPath+"] could not handle event";
this.notifyErrorListeners(error,msg);
throw error;
}
}.bind(this));
this.eventListeners.forEach(function(listener) {
try {
listener(event);
} catch (error) {
var msg = "Event listener could not handle event " + event.type + " => " + listener;
this.notifyErrorListeners(error,msg);
throw error;
}
}.bind(this));
};
// TODO this method is probably useless
AtomReactContext.prototype.startWithEvent = function(bootstrapEvent) {
Preconditions.checkHasValue(this.mountConfig,"Mount config is mandatory");
Preconditions.checkHasValue(this.stores,"Stores array is mandatory");
Preconditions.checkHasValue(this.actions,"Actions object is mandatory");
console.debug("Starting AtomReactContext",this);
this.publishEvent(bootstrapEvent);
};
AtomReactContext.prototype.transact = function(task) {
if ( this.firstTransactionStatus == "error" ) {
console.info("Because of startup error: ignoring subsequent transactional tasks");
return;
}
try {
this.atom.transact(task);
if ( !this.firstTransactionStatus ) {
this.firstTransactionStatus = "success";
}
}
catch (e) {
if ( !this.firstTransactionStatus ) {
this.firstTransactionStatus = "error";
var msg = "Serious error on application startup!";
this.notifyErrorListeners(e,msg);
console.error(msg,e);
}
throw e;
}
};
AtomReactContext.prototype.renderCurrentAtomState = function() {
this.renderAtomState(this.atom);
};
AtomReactContext.prototype.renderAtomState = function(atomToRender) {
var props = {
appStateCursor: atomToRender.cursor(AtomCursorMemoizedOption)
};
var reactContextHolder = this.getMemoizedReactContextHolder(atomToRender);
try {
this.logStateBeforeRender();
var timeBeforeRendering = Date.now();
// atomToRender.doWithLock("Atom state should not be modified during the render phase",function() {
// TODO 0.13 temporary ?, See https://github.com/facebook/react/issues/3392
var componentFactory = this.mountConfig.reactElementFactory;
var componentProvider = function() { return componentFactory(props); };
var componentWithContext = reactContextHolder.childContextProviderFactory({componentProvider: componentProvider, context: reactContextHolder.context});
ReactDOM.render(componentWithContext, this.mountConfig.domNode, function() {
if ( this.verboseStateChangeLog || this.lightStateChangeLog ) {
console.debug("Time to render in millies",Date.now()-timeBeforeRendering);
}
}.bind(this));
// }.bind(this));
} catch (error) {
this.notifyErrorListeners(error,"AtomReact rendering error");
throw error;
}
};
function ChildContextProviderFactory(context) {
// TODO we are very permissive on the childContextTypes (is it a good idea?)
var childContextTypes = {};
Object.keys(context).forEach(function(contextKey) {
childContextTypes[contextKey] = React.PropTypes.any.isRequired
});
return React.createFactory(React.createClass({
displayName: "ChildContextProvider",
childContextTypes: childContextTypes,
propTypes: {
componentProvider: React.PropTypes.func.isRequired,
context: React.PropTypes.object.isRequired
},
getChildContext: function() {
return this.props.context;
},
render: function() {
// TODO simplify this "componentProvider hack" after React 0.14? See See https://github.com/facebook/react/issues/3392
var children = this.props.componentProvider();
return children;
}
}));
}
AtomReactContext.prototype.logStateBeforeRender = function() {
if ( this.verboseStateChangeLog ) {
var previousState = this.lastRenderedState;
var currentState = this.atom.get();
this.lastRenderedState = currentState;
console.debug("###########################################################\n# Rendering state",this.atom.get());
var pathDiff = AtomUtils.getPathDiff(previousState,currentState);
pathDiff.forEach(function(path) {
var beforeValue = AtomUtils.getPathValue(previousState,path);
var afterValue = AtomUtils.getPathValue(currentState,path);
if ( Preconditions.hasValue(beforeValue) && Preconditions.hasValue(afterValue) ) {
console.debug("%cX","color: orange; background-color: orange;","["+path.toString()+"][",beforeValue," -> ",afterValue,"]");
}
else if ( Preconditions.hasValue(beforeValue) && !Preconditions.hasValue(afterValue) ) {
console.debug("%cX","color: red; background-color: red;","["+path.toString()+"]");
}
else if ( !Preconditions.hasValue(beforeValue) && Preconditions.hasValue(afterValue) ) {
console.debug("%cX","color: green; background-color: green;","["+path.toString()+"][",afterValue,"]");
}
});
} else if ( this.lightStateChangeLog ) {
console.debug("Rendering state",this.atom.get());
}
};