Source: Morearty.js

/**
 * @name Morearty
 * @namespace
 * @classdesc Morearty main module. Exposes [createContext]{@link Morearty.createContext} function.
 */
var Imm      = require('immutable');
var Util     = require('./Util');
var Binding  = require('./Binding');
var History  = require('./History');
var Callback = require('./util/Callback');

var MERGE_STRATEGY = Object.freeze({
  OVERWRITE: 'overwrite',
  OVERWRITE_EMPTY: 'overwrite-empty',
  MERGE_PRESERVE: 'merge-preserve',
  MERGE_REPLACE: 'merge-replace'
});

var getBinding, bindingStateChanged, stateChanged;

getBinding = function (props, key) {
  var binding = props.binding;
  return key ? binding[key] : binding;
};

bindingStateChanged = function (context, currentBinding, previousState, previousMetaState) {
  return (context._stateChanged && previousState !== currentBinding.get()) ||
    (context._metaChanged && context._metaBinding.sub(currentBinding.getPath()).isChanged(previousMetaState));
};

stateChanged = function (self, currentBinding, previousBinding, previousState, previousMetaState) {
  if (!currentBinding) return false;
  else {
    var context = self.getMoreartyContext();

    if (currentBinding instanceof Binding) {
      return currentBinding !== previousBinding || bindingStateChanged(context, currentBinding, previousState, previousMetaState);
    } else {
      if (context._stateChanged || context._metaChanged) {
        var keys = Object.keys(currentBinding);
        return !!Util.find(keys, function (key) {
          var binding = currentBinding[key];
          return binding &&
            (binding !== previousBinding[key] || bindingStateChanged(context, binding, previousState[key], previousMetaState));
        });
      } else {
        return false;
      }
    }
  }
};

var propChanged, countProps, propsChanged;

propChanged = function (prop, currentProps, previousProps) {
  return currentProps[prop] !== previousProps[prop];
};

countProps = function (props) {
  var count = 0;
  for (var ignore in props) ++count;
  return count;
};

propsChanged = function (self, currentProps) {
  var effectiveCurrentProps = currentProps || {}, effectivePreviousProps = self.props || {};

  if (countProps(effectiveCurrentProps) !== countProps(effectivePreviousProps)) {
    return true;
  } else {
    for (var prop in effectiveCurrentProps) {
      //noinspection JSUnfilteredForInLoop
      if (prop !== 'binding' && propChanged(prop, effectiveCurrentProps, effectivePreviousProps)) return true;
    }
    return false;
  }
};

var merge = function (mergeStrategy, defaultState, stateBinding) {
  var tx = stateBinding.atomically();

  if (typeof mergeStrategy === 'function') {
    tx = tx.update(function (currentState) {
      return mergeStrategy(currentState, defaultState);
    });
  } else {
    switch (mergeStrategy) {
      case MERGE_STRATEGY.OVERWRITE:
        tx = tx.set(defaultState);
        break;
      case MERGE_STRATEGY.OVERWRITE_EMPTY:
        tx = tx.update(function (currentState) {
          var empty = Util.undefinedOrNull(currentState) ||
            (Imm.Iterable.isIterable(currentState) && currentState.isEmpty());
          return empty ? defaultState : currentState;
        });
        break;
      case MERGE_STRATEGY.MERGE_PRESERVE:
        tx = tx.merge(true, defaultState);
        break;
      case MERGE_STRATEGY.MERGE_REPLACE:
        tx = tx.merge(false, defaultState);
        break;
      default:
        throw new Error('Invalid merge strategy: ' + mergeStrategy);
    }
  }

  tx.commit({ notify: false });
};

var getRenderRoutine = function (self) {
  var requestAnimationFrame = (typeof window !== 'undefined') && window.requestAnimationFrame;
  var fallback = function (f) { setTimeout(f, 1000 / 60); };

  if (self._options.requestAnimationFrameEnabled) {
    if (requestAnimationFrame) return requestAnimationFrame;
    else {
      console.warn('Morearty: requestAnimationFrame is not available, will render using setTimeout');
      return fallback;
    }
  } else {
    return fallback;
  }
};

var initState, initDefaultState, initDefaultMetaState, savePreviousState;

initState = function (self, getStateMethodName, f) {
  if (typeof self[getStateMethodName] === 'function') {
    var defaultStateValue = self[getStateMethodName]();
    if (defaultStateValue) {
      var binding = getBinding(self.props);
      var mergeStrategy =
        typeof self.getMergeStrategy === 'function' ? self.getMergeStrategy() : MERGE_STRATEGY.MERGE_PRESERVE;

      var immutableInstance = Imm.Iterable.isIterable(defaultStateValue);

      if (binding instanceof Binding) {
        var effectiveDefaultStateValue = immutableInstance ? defaultStateValue : defaultStateValue['default'];
        merge(mergeStrategy, effectiveDefaultStateValue, f(binding));
      } else {
        var keys = Object.keys(binding);
        var defaultKey = keys.length === 1 ? keys[0] : 'default';
        var effectiveMergeStrategy = typeof mergeStrategy === 'string' ? mergeStrategy : mergeStrategy[defaultKey];

        if (immutableInstance) {
          merge(effectiveMergeStrategy, defaultStateValue, f(binding[defaultKey]));
        } else {
          keys.forEach(function (key) {
            if (defaultStateValue[key]) {
              merge(effectiveMergeStrategy, defaultStateValue[key], f(binding[key]));
            }
          });
        }
      }
    }
  }
};

initDefaultState = function (self) {
  initState(self, 'getDefaultState', Util.identity);
};

initDefaultMetaState = function (self) {
  initState(self, 'getDefaultMetaState', function (b) { return b.meta(); });
};

savePreviousState = function (self) {
  var binding = self.props.binding;
  if (binding) {
    var ctx = self.getMoreartyContext();
    self._previousMetaState = ctx && ctx.getCurrentMeta();
    if (binding instanceof Binding) {
      self._previousState = binding.get();
    } else {
      self._previousState = {};
      Object.keys(self.props.binding)
        .forEach(function (key) {
          self._previousState[key] = self.props.binding[key] && self.props.binding[key].get();
        });
    }
  } else {
    self._previousState = null;
    self._previousMetaState = null;
  }
};

var addComponentToRenderQueue, removeComponentFromRenderQueue, getUniqueComponentQueueId, setupObservedBindingListener;

addComponentToRenderQueue = function (self, component) {
  self._componentQueue[component.componentQueueId] = component;
};

removeComponentFromRenderQueue = function (self, component) {
  delete self._componentQueue[component.componentQueueId];
};

getUniqueComponentQueueId = function (self) {
  return self ? ++self._lastComponentQueueId : 0;
};

setupObservedBindingListener = function (self, binding) {
  if (!self._observedListenerRemovers) {
    self._observedListenerRemovers = [];
  }

  var listenerId = binding.addListener(function () {
    addComponentToRenderQueue(self.getMoreartyContext(), self);
  });

  self._observedListenerRemovers.push(function () {
    binding.removeListener(listenerId);
  });
};

var defaultLogger = {
  error: function (message, cause) {
    console.error(message);
    console.error('Error details: %s', cause.message, cause.stack);
  }
};

module.exports = function (React, DOM) {
  /** Morearty context constructor.
   * @param {Binding} binding state binding
   * @param {Binding} metaBinding meta state binding
   * @param {Object} options options
   * @public
   * @class Context
   * @classdesc Represents Morearty context.
   * <p>Exposed modules:
   * <ul>
   *   <li>[Util]{@link Util};</li>
   *   <li>[Binding]{@link Binding};</li>
   *   <li>[History]{@link History};</li>
   *   <li>[Callback]{@link Callback};</li>
   *   <li>[DOM]{@link DOM}.</li>
   * </ul> */
  var Context = function (binding, metaBinding, options) {
    /** @private */
    this._initialMetaState = metaBinding.get();
    /** @private */
    this._previousMetaState = null;
    /** @private */
    this._metaBinding = metaBinding;
    /** @protected
     * @ignore */
    this._metaChanged = false;

    /** @private */
    this._initialState = binding.get();
    /** @protected
     * @ignore */
    this._previousState = null;
    /** @private */
    this._stateBinding = binding;
    /** @protected
     * @ignore */
    this._stateChanged = false;

    /** @private */
    this._options = options;

    /** @private */
    this._renderQueued = false;
    /** @private */
    this._fullUpdateQueued = false;
    /** @protected
     * @ignore */
    this._fullUpdateInProgress = false;

    /** @private */
    this._componentQueue = [];
    /** @private */
    this._lastComponentQueueId = 0;
  };

  /** @lends Context.prototype */
  var contextPrototype = {
    /** Get state binding.
     * @return {Binding} state binding
     * @see Binding */
    getBinding: function () {
      return this._stateBinding;
    },

    /** Get meta binding.
     * @return {Binding} meta binding
     * @see Binding */
    getMetaBinding: function () {
      return this._metaBinding;
    },

    /** Get current state.
     * @return {Immutable.Map} current state */
    getCurrentState: function () {
      return this.getBinding().get();
    },

    /** Get previous state (before last render).
     * @return {Immutable.Map} previous state */
    getPreviousState: function () {
      return this._previousState;
    },

    /** Get current meta state.
     * @returns {Immutable.Map} current meta state */
    getCurrentMeta: function () {
      var metaBinding = this.getMetaBinding();
      return metaBinding ? metaBinding.get() : undefined;
    },

    /** Get previous meta state (before last render).
     * @return {Immutable.Map} previous meta state */
    getPreviousMeta: function () {
      return this._previousMetaState;
    },

    /** Create a copy of this context sharing same bindings and options.
     * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
     * @returns {Context} */
    copy: function (subpath) {
      return new Context(this._stateBinding.sub(subpath), this._metaBinding.sub(subpath), this._options);
    },

    /** Revert to initial state.
     * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
     * @param {Object} [options] options object
     * @param {Boolean} [options.notify=true] should listeners be notified
     * @param {Boolean} [options.resetMeta=true] should meta state be reverted */
    resetState: function (subpath, options) {
      var args = Util.resolveArgs(
        arguments,
        function (x) { return Util.canRepresentSubpath(x) ? 'subpath' : null; }, '?options'
      );

      var pathAsArray = args.subpath ? Binding.asArrayPath(args.subpath) : [];

      var tx = this.getBinding().atomically();
      tx.set(pathAsArray, this._initialState.getIn(pathAsArray));

      var effectiveOptions = args.options || {};
      if (effectiveOptions.resetMeta !== false) {
        tx.set(this.getMetaBinding(), pathAsArray, this._initialMetaState.getIn(pathAsArray));
      }

      tx.commit({ notify: effectiveOptions.notify });
    },

    /** Replace whole state with new value.
     * @param {Immutable.Map} newState new state
     * @param {Immutable.Map} [newMetaState] new meta state
     * @param {Object} [options] options object
     * @param {Boolean} [options.notify=true] should listeners be notified */
    replaceState: function (newState, newMetaState, options) {
      var args = Util.resolveArgs(
        arguments,
        'newState', function (x) { return Imm.Map.isMap(x) ? 'newMetaState' : null; }, '?options'
      );

      var effectiveOptions = args.options || {};

      var tx = this.getBinding().atomically();
      tx.set(newState);

      if (args.newMetaState) tx.set(this.getMetaBinding(), args.newMetaState);

      tx.commit({ notify: effectiveOptions.notify });
    },

    /** Check if binding value was changed on last re-render.
     * @param {Binding} binding binding
     * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
     * @param {Function} [compare] compare function, '===' for primitives / Immutable.is for collections by default */
    isChanged: function (binding, subpath, compare) {
      var args = Util.resolveArgs(
        arguments,
        'binding', function (x) { return Util.canRepresentSubpath(x) ? 'subpath' : null; }, '?compare'
      );

      return args.binding.sub(args.subpath).isChanged(this._previousState, args.compare || Imm.is);
    },

    /** Initialize rendering.
     * @param {*} rootComp root application component */
    init: function (rootComp) {
      var self = this;
      var stop = false;
      var renderQueue = [];

      var transitionState = function () {
        var stateChanged, metaChanged;

        if (renderQueue.length === 1) {
          var singleFrame = renderQueue[0];

          stateChanged = singleFrame.stateChanged;
          metaChanged = singleFrame.metaChanged;

          if (stateChanged) self._previousState = singleFrame.previousState;
          if (metaChanged) self._previousMetaState = singleFrame.previousMetaState;
        } else {
          var elderStateChangedFrame = Util.find(renderQueue, function (q) { return q.stateChanged; });
          var elderMetaChangedFrame = Util.find(renderQueue, function (q) { return q.metaChanged; });

          stateChanged = !!elderStateChangedFrame;
          metaChanged = !!elderMetaChangedFrame;

          if (stateChanged) self._previousState = elderStateChangedFrame.previousState;
          if (metaChanged) self._previousMetaState = elderMetaChangedFrame.previousMetaState;
        }

        self._stateChanged = stateChanged;
        self._metaChanged = metaChanged;

        renderQueue = [];
      };

      var forceUpdate = function (comp, f) {
        if (comp.isMounted()) {
          comp.forceUpdate(f);
        }
      };

      var logError = function (message, cause) {
        if (self._options.logger) {
          try {
            self._options.logger.error(message, cause);
          }
          catch (e) {
            defaultLogger.error(message, cause);
          }
        }
      };

      var catchingRenderErrors = function (f) {
        try {
          f();
        } catch (e) {
          if (self._options.stopOnRenderError) {
            stop = true;
          }

          logError('Morearty: render error. ' + (stop ? 'Will exit on next render attempt.' : 'Continuing.'), e);
        }
      };

      var render = function () {
        transitionState();

        self._renderQueued = false;

        catchingRenderErrors(function () {
          if (self._fullUpdateQueued) {
            self._fullUpdateInProgress = true;

            forceUpdate(rootComp, function () {
              self._fullUpdateQueued = false;
              self._fullUpdateInProgress = false;
            });
          } else {
            forceUpdate(rootComp);

            self._componentQueue.forEach(function (c) {
              forceUpdate(c);
              savePreviousState(c);
            });
            self._componentQueue = [];
          }
        });
      };

      if (!self._options.renderOnce) {
        var renderRoutine = getRenderRoutine(self);

        var listenerId = self._stateBinding.addListener(function (changes) {
          if (stop) {
            self._stateBinding.removeListener(listenerId);
          } else {
            var stateChanged = changes.isValueChanged(), metaChanged = changes.isMetaChanged();

            if (stateChanged || metaChanged) {
              renderQueue.push({
                stateChanged: stateChanged,
                metaChanged: metaChanged,
                previousState: (stateChanged || null) && changes.getPreviousBackingValue(),
                previousMetaState: (metaChanged || null) && changes.getPreviousBackingMeta()
              });

              if (!self._renderQueued) {
                self._renderQueued = true;
                renderRoutine(render);
              }
            }
          }
        });
      }

      catchingRenderErrors(rootComp.forceUpdate.bind(rootComp));
    },

    /** Queue full update on next render. */
    queueFullUpdate: function () {
      this._fullUpdateQueued = true;
    },

    /** Create Morearty bootstrap component ready for rendering.
     * @param {*} rootComp root application component
     * @param {Object} [reactContext] custom React context (will be enriched with Morearty-specific data)
     * @return {*} Morearty bootstrap component */
    bootstrap: function (rootComp, reactContext) {
      var ctx = this;

      var effectiveReactContext = reactContext || {};
      effectiveReactContext.morearty = ctx;

      return React.createClass({
        displayName: 'Bootstrap',

        childContextTypes: {
          morearty: React.PropTypes.instanceOf(Context).isRequired
        },

        getChildContext: function () {
          return effectiveReactContext;
        },

        componentWillMount: function () {
          ctx.init(this);
        },

        render: function () {
          var effectiveProps = Util.assign({}, {binding: ctx.getBinding()}, this.props);

          return React.createFactory(rootComp)(effectiveProps);
        }
      });
    }

  };

  Context.prototype = contextPrototype;

  return {

    /** Binding module.
     * @memberOf Morearty
     * @see Binding */
    Binding: Binding,

    /** History module.
     * @memberOf Morearty
     * @see History */
    History: History,

    /** Util module.
     * @memberOf Morearty
     * @see Util */
    Util: Util,

    /** Callback module.
     * @memberOf Morearty
     * @see Callback */
    Callback: Callback,

    /** DOM module.
     * @memberOf Morearty
     * @see DOM */
    DOM: DOM,

    /** Merge strategy.
     * <p>Describes how existing state should be merged with component's default state on mount. Predefined strategies:
     * <ul>
     *   <li>OVERWRITE - overwrite current state with default state;</li>
     *   <li>OVERWRITE_EMPTY - overwrite current state with default state only if current state is null or empty collection;</li>
     *   <li>MERGE_PRESERVE - deep merge current state into default state;</li>
     *   <li>MERGE_REPLACE - deep merge default state into current state.</li>
     * </ul>
     * @memberOf Morearty */
    MergeStrategy: MERGE_STRATEGY,

    /** Morearty mixin.
     * @memberOf Morearty
     * @namespace
     * @classdesc Mixin */
    Mixin: {

      contextTypes: {
        morearty: React.PropTypes.instanceOf(Context).isRequired
      },

      /** Get Morearty context.
       * @returns {Context} */
      getMoreartyContext: function () {
        return this.context.morearty;
      },

      /** Get component state binding. Returns binding specified in component's binding attribute.
       * @param {String} [name] binding name (can only be used with multi-binding state)
       * @return {Binding|Object} component state binding */
      getBinding: function (name) {
        return getBinding(this.props, name);
      },

      /** Get default component state binding. Use this to get component's binding.
       * <p>Default binding is single binding for single-binding components or
       * binding with key 'default' for multi-binding components or else first observed binding, if any.
       * This method allows smooth migration from single to multi-binding components, e.g. you start with:
       * <pre><code>{ binding: foo }</code></pre>
       * or
       * <pre><code>{ binding: { default: foo } }</code></pre>
       * or even
       * <pre><code>{ binding: { any: foo } }</code></pre>
       * and add more bindings later:
       * <pre><code>{ binding: { default: foo, aux: auxiliary } }</code></pre>
       * This way code changes stay minimal.
       * @return {Binding} default component state binding */
      getDefaultBinding: function () {
        var binding = getBinding(this.props);
        if (binding) {
          if (binding instanceof Binding) {
            return binding;
          } else if (typeof binding === 'object') {
            var keys = Object.keys(binding);
            return keys.length === 1 ? binding[keys[0]] : binding['default'];
          }
        } else {
          return this.observedBindings && this.observedBindings[0];
        }
      },

      /** Get component previous state value.
       * @param {String} [name] binding name (can only be used with multi-binding state)
       * @return {*} previous component state value */
      getPreviousState: function (name) {
        var ctx = this.getMoreartyContext();
        return getBinding(this.props, name).withBackingValue(ctx._previousState).get();
      },

      /** Consider specified binding for changes when rendering. Registering same binding twice has no effect.
       * @param {Binding} binding
       * @param {Function} [cb] optional callback receiving binding value
       * @return {*} undefined if cb argument is ommitted, cb invocation result otherwise */
      observeBinding: function (binding, cb) {
        if (!this.observedBindings) {
          this.observedBindings = [];
        }

        var bindingPath = binding.getPath();
        if (!Util.find(this.observedBindings, function (b) { return b.getPath() === bindingPath; })) {
          this.observedBindings.push(binding);
          setupObservedBindingListener(this, binding);
        }

        return cb ? cb(binding.get()) : undefined;
      },

      componentWillMount: function () {
        this.componentQueueId = getUniqueComponentQueueId(this.getMoreartyContext());

        savePreviousState(this);
        initDefaultState(this);
        initDefaultMetaState(this);

        if (this.observedBindings) {
          this.observedBindings.forEach(setupObservedBindingListener.bind(null, this));
        }
      },

      shouldComponentUpdate: function (nextProps, nextState, nextContext) {
        var self = this;
        var ctx = self.getMoreartyContext();
        var previousState = self._previousState;
        var previousMetaState = self._previousMetaState;

        savePreviousState(self);

        var shouldComponentUpdate = function () {
          return ctx._fullUpdateInProgress ||
            stateChanged(self, getBinding(nextProps), getBinding(self.props), previousState, previousMetaState) ||
            propsChanged(self, nextProps);
        };

        var shouldComponentUpdateOverride = self.shouldComponentUpdateOverride;
        return shouldComponentUpdateOverride ?
          shouldComponentUpdateOverride(shouldComponentUpdate, nextProps, nextState, nextContext) :
          shouldComponentUpdate();
      },

      /** Add binding listener. Listener will be automatically removed on unmount.
       * @param {Binding} [binding] binding to attach listener to, default binding if omitted
       * @param {String|Array} [subpath] subpath as a dot-separated string or an array of strings and numbers
       * @param {Function} cb function receiving changes descriptor
       * @return {String} listener id */
      addBindingListener: function (binding, subpath, cb) {
        var args = Util.resolveArgs(
          arguments,
          function (x) { return x instanceof Binding ? 'binding' : null; },
          function (x) { return Util.canRepresentSubpath(x) ? 'subpath' : null; },
          'cb'
        );

        if (!this._bindingListenerRemovers) {
          this._bindingListenerRemovers = [];
        }

        var effectiveBinding = args.binding || this.getDefaultBinding();
        if (!effectiveBinding) {
          return console.warn('Morearty: cannot attach binding listener to a component without default binding');
        }
        var listenerId = effectiveBinding.addListener(args.subpath, args.cb);
        this._bindingListenerRemovers.push(function () {
          effectiveBinding.removeListener(listenerId);
        });

        return listenerId;
      },

      componentDidUpdate: function () {
        removeComponentFromRenderQueue(this.getMoreartyContext(), this);
      },

      componentWillUnmount: function () {
        if (this._observedListenerRemovers) {
          this._observedListenerRemovers.forEach(function (remover) { remover(); });
          this._observedListenerRemovers = [];
        }

        if (this._bindingListenerRemovers) {
          this._bindingListenerRemovers.forEach(function (remover) { remover(); });
          this._bindingListenerRemovers = [];
        }
      }

    },

    /** Create Morearty context.
     * @param {Object} [spec] spec object
     * @param {Immutable.Map|Object} [spec.initialState={}] initial state
     * @param {Immutable.Map|Object} [spec.initialMetaState={}] initial meta-state
     * @param {Object} [spec.options={}] options object
     * @param {Boolean} [spec.options.requestAnimationFrameEnabled=true] enable rendering in requestAnimationFrame
     * @param {Boolean} [spec.options.renderOnce=false]
     *                  ensure render is executed only once (useful for server-side rendering to save resources),
     *                  any further state updates are ignored
     * @param {Boolean} [spec.options.stopOnRenderError=false] stop on errors during render
     * @param {Object} [spec.options.logger=undefined] an optional logger object for reporting errors
     * @param {function} [spec.options.logger.error] function accepting error message and optional cause
     * @return {Context}
     * @memberOf Morearty */
    createContext: function (spec) {
      var initialState, initialMetaState, options;
      if (arguments.length <= 1) {
        var effectiveSpec = spec || {};
        initialState = effectiveSpec.initialState;
        initialMetaState = effectiveSpec.initialMetaState;
        options = effectiveSpec.options;
      } else {
        console.warn(
          'Passing multiple arguments to createContext is deprecated. Use single object form instead.'
        );

        initialState = arguments[0];
        initialMetaState = arguments[1];
        options = arguments[2];
      }

      var ensureImmutable = function (state) {
        return Imm.Iterable.isIterable(state) ? state : Imm.fromJS(state);
      };

      var state = ensureImmutable(initialState || {});
      var metaState = ensureImmutable(initialMetaState || {});

      var metaBinding = Binding.init(metaState);
      var binding = Binding.init(state, metaBinding);

      var effectiveOptions = options || {};
      return new Context(binding, metaBinding, {
        requestAnimationFrameEnabled: effectiveOptions.requestAnimationFrameEnabled !== false,
        renderOnce: effectiveOptions.renderOnce || false,
        stopOnRenderError: effectiveOptions.stopOnRenderError || false,
        logger: effectiveOptions.logger || defaultLogger
      });
    }

  };
};