store.js

import { merge, deleted } from "./merge";

const isArray = Array.isArray;

/**
 * @module SimpleSharedState
 */

export class Store {
	/**
	 * @description Create a new store instance.
	 *
	 * @constructor
	 * @param {object} initialState - Any plain JS object (Arrays not allowed at the top level).
	 * @param {function} [actions] - A function, which takes a reference to `store`, and returns an object of
	 * actions for invoking changes to state.
	 * @param {function} [devtool] - Provide a reference to `window.__REDUX_DEVTOOLS_EXTENSION__` to enable
	 * redux devtools.
	 */
	constructor(initialState = {}, getActions, useDevtool) {
		this.devtool;
		this.stateTree = Object.assign({}, initialState);
		this.dispatching = false;
		this.listeners = new Map();
		this.snapshots = new Map();
		this.dispatchListeners = new Set();
		this.actions = {};

		if (useDevtool && useDevtool.connect && typeof useDevtool.connect === "function") {
			// this adapts SimpleSharedState to work with redux devtools
			this.devtool = useDevtool.connect();
			this.devtool.subscribe((message) => {
				if (message.type === "DISPATCH" && message.payload.type === "JUMP_TO_STATE") {
					this._applyBranch(JSON.parse(message.state));
				}
			});
			this.devtool.init(this.stateTree);
		}

		if (getActions && typeof getActions === "function") {
			const actions = getActions(this.getState.bind(this));

			Object.keys(actions).forEach((actionName) => {
				const actionType = this.devtool ? `${actionName}()` : "unknown";

				this.actions[actionName] = (...args) => {
					this.dispatchTyped(actionType, actions[actionName].apply(null, args));
				};
			});
		}
	}

	_applyBranch(branch) {
		this.dispatching = true;
		merge(this.stateTree, branch);

		this.listeners.forEach((handler, selector) => {
			let change;
			const snapshot = this.snapshots.get(selector);
			const submit = (value) => {
				this.snapshots.set(selector, value);
				handler(value);
			};

			try {
				// attempt selector only on the branch
				change = selector(branch);

				switch(change) {
					case snapshot:
						return;
					case deleted:
						if (snapshot !== undefined) submit(undefined);
						return;
					case undefined:
						change = selector(this.stateTree);
						// If ^this line throws, then **current state is also not applicable**,
						// meaning something was deleted, so we should proceed to catch block.
						return;
						// If `return` runs, then selector didn't throw, so exit early.
				}
			}
			catch (_) {
				try {
					selector(this.stateTree);
					// If ^selector works on new state then exit early.
					return;
				} catch (_) {}
			}

			// This test also covers the scenario where both are undefined.
			if (change === snapshot) return;

			if (isArray(change) && !change.isPartial) {
				submit(change);
			} else {
				submit(merge(snapshot, change));
			}
		});

		this.dispatchListeners.forEach((callback) => callback());
		this.dispatching = false;
	};

	/**
	 * @method module:SimpleSharedState.Store#watch
	 * @param {function} selector - A pure function which takes state and returns a piece of that state.
	 * @param {function} handler - The listener which will receive the piece of state when changes occur.
	 * @param {boolean} [runNow=true] - Pass false to prevent `handler` from being called immediately
	 * after watch is called.
	 * @returns {function} Invoke this function to destroy the listener.
	 *
	 * @description Creates a state listener which is associated with the selector. Every selector must
	 * be globally unique, as they're stored internally in a Set. If `watch` receives a selector which
	 * has already been passed before, `watch` will throw. Refer to the tests for more examples. `watch`
	 * returns a function which, when called, removes the watcher / listener.
	 */
	watch(selector, handler, runNow = true) {
		if (typeof selector !== "function" || typeof handler !== "function") {
			throw new Error("selector and handler must be functions");
		}
		if (this.listeners.has(selector)) {
			throw new Error("Cannot reuse selector");
		}

		let snapshot;
		try {
			snapshot = selector(this.stateTree);
			if (runNow) handler(snapshot);
		} catch (_) {}

		this.listeners.set(selector, handler);
		this.snapshots.set(selector, snapshot);

		return () => {
			this.listeners.delete(selector);
			this.snapshots.delete(selector);
		};
	};

	/**
	 * @method module:SimpleSharedState.Store#watchBatch
	 * @param {Array<function>|Set<function>} selectors - A Set or Array of selector functions. Refer to
	 * [Store#watch]{@link module:SimpleSharedState.Store#watch} for details about selector functions.
	 * @param {function} handler - The listener which will receive the Array of state snapshots.
	 * @returns {function} A callback that removes the dispatch watcher and cleans up after itself.
	 *
	 * @description Creates a dispatch listener from a list of selectors. Each selector yields a snapshot,
	 * which is stored in an array and updated whenever the state changes. When dispatch happens, your
	 * `handler` function will be called with the array of snapshots, ***if*** any snapshots have changed.
	 *
	 * @example
	 * import { Store, partialArray } from "simple-shared-state";
	 *
	 * const store = new Store({
	 *   people: ["Alice", "Bob"],
	 * });
	 *
	 * const unwatch = store.watchBatch([
	 *   (state) => state.people[0],
	 *   (state) => state.people[1],
	 * ], (values) => console.log(values));
	 *
	 * store.dispatch({ people: partialArray(1, "John") });
	 * // [ 'Alice', 'John' ]
	 *
	 * store.dispatch({ people: [ "Janet", "Jake", "James" ] });
	 * // [ 'Janet', 'Jake' ]
	 * // notice "James" is not present, that's because of our selectors
	 *
	 * console.log(store.getState(s => s.people));
	 * // [ 'Janet', 'Jake', 'James' ]
	 *
	 * unwatch();
	 * store.dispatch({ people: [ "Justin", "Josh", store.deleted ] });
	 * // nothing happens, the watcher was removed
	 *
	 * console.log(store.getState(s => s.people));
	 * // [ 'Justin', 'Josh', <1 empty item> ]
	 */
	watchBatch(selectors, handler) {
		if (!selectors || typeof selectors.forEach !== "function") {
			throw new Error("selectors must be a list of functions");
		}
		if (typeof handler !== "function") throw new Error("handler is not a function");

		const snapshotsArray = [];

		let i = 0;
		let changed = false;
		selectors.forEach((fn) => {
			if (typeof fn !== "function") {
				selectors.forEach((fn) => this.listeners.delete(fn));
				throw new Error("selector must be a function");
			}

			let pos = i++; // pos = 0, i += 1
			try {
				snapshotsArray[pos] = fn(this.stateTree);
			} catch (_) {
				snapshotsArray[pos] = undefined;
			}
			this.watch(fn, (snapshot) => {
				snapshotsArray[pos] = snapshot;
				changed = true;
			}, false);
		});

		const watchHandler = () => {
			if (changed) {
				handler(snapshotsArray.slice());
				changed = false;
			}
		};
		this.dispatchListeners.add(watchHandler);

		handler(snapshotsArray.slice());

		return () => {
			this.dispatchListeners.delete(watchHandler);
			selectors.forEach((fn) => this.listeners.delete(fn));
		};
	};

	/**
	 * @method module:SimpleSharedState.Store#watchDispatch
	 *
	 * @description Listen for the after-dispatch event, which gets called with no arguments after every
	 * dispatch completes. Dispatch is complete after all watchers have been called.
	 *
	 * @param {function} handler - A callback function.
	 */
	watchDispatch(handler) {
		if (typeof handler !== "function") throw new Error("handler must be a function");
		this.dispatchListeners.add(handler);
		return () => this.dispatchListeners.delete(handler);
	};

	/**
	 * @method module:SimpleSharedState.Store#getState
	 *
	 * @param {function} [selector] - Optional but recommended function which returns a piece of the state.
	 * Error handling not required, your selector will run inside a `try{} catch{}` block.
	 * @returns {*} A shallow copy of the state tree, or a copy of the piece returned from the selector,
	 * or undefined if the selector fails.
	 */
	getState(selector) {
		if (selector && typeof selector === "function") {
			let piece;
			try {
				piece = copy(selector(this.stateTree));
			} catch (_) {}

			return piece;
		}

		return Object.assign({}, this.stateTree);
	};

	/**
	 * @method module:SimpleSharedState.Store#dispatchTyped
	 *
	 * @param {string} actionName - This is only for the benefit of providing a label in redux devtools.
	 * @param {object|function} branch - A JavaScript object, or a function which takes state and returns a
	 * JavaScript object. The object may contain any Array or JS primitive, but must be a plain JS object
	 * at the top level, otherwise dispatch will throw.
	 *
	 * @description Please use [action creators]{@link module:SimpleSharedState#Store} instead of calling
	 * dispatchTyped directly.
	 */
	dispatchTyped(actionName = "unknown", branch) {
		if (this.dispatching) throw new Error("can't dispatch while dispatching");

		if (!branch) throw new Error("can't dispatch invalid branch");

		if (typeof branch === "function") {
			branch = branch(this.getState());
		}
		if (typeof branch !== "object") {
			throw new Error("dispatch got invalid branch");
		}
		this._applyBranch(branch);

		if (this.devtool) this.devtool.send(actionName, this.getState());
	};

	/**
	 * @method module:SimpleSharedState.Store#dispatch
	 *
	 * @param {object|function} branch - A JavaScript object, or a function which takes state and returns a
	 * JavaScript object. The object may contain any Array or JS primitive, but must be a plain JS object
	 * at the top level, otherwise dispatch will throw.
	 *
	 * @description Please use [action creators]{@link module:SimpleSharedState#Store} instead of calling
	 * dispatch directly.
	 *
	 * @example
	 * import { Store } from "simple-shared-state";
	 *
	 * // Create a store with state:
	 * const store = new Store({
	 *   email: "[email protected]",
	 *   counters: {
	 *     likes: 1,
	 *   },
	 *   todoList: [
	 *     { label: "buy oat milk" },
	 *     { label: "buy cat food" },
	 *   ],
	 * });
	 *
	 * // To change email, call dispatch with a branch. The branch you provide must include the full path
	 * // from the root of the state, to the value you want to change.
	 * store.dispatch({
	 *   email: "[email protected]",
	 * });
	 *
	 * // To increment likes:
	 * store.dispatch((state) => ({
	 *   counters: {
	 *     likes: state.counters.likes + 1,
	 *   },
	 * }));
	 *
	 * // To delete any piece of state, use a reference to `store.deleted` as the value in the branch.
	 * // To remove `counters` from the state entirely:
	 * store.dispatch({
	 *   counters: store.deleted,
	 * });
	 *
	 * // To update items in arrays, you can use `partialArray`:
	 * store.dispatch({
	 *   todoList: partialArray(1, {
	 *     label: "buy oat milk (because it requires 80 times less water than almond milk)",
	 *   }),
	 * });
	 */
	dispatch(branch) {
		this.dispatchTyped("unknown", branch);
	}
};

function copy(thing) {
	return !thing || typeof thing !== "object" ? thing : Object.assign(isArray(thing) ? [] : {}, thing);
}