Source

Updux.js

/* TODO change * for leftovers to +, change subscriptions to reactions */
import moize from 'moize';
import u from '@yanick/updeep';
import { createStore as reduxCreateStore, applyMiddleware } from 'redux';
import { get, map, mapValues, merge, difference } from 'lodash-es';

import { buildInitial } from './buildInitial/index.js';
import { buildActions } from './buildActions/index.js';
import { buildSelectors } from './buildSelectors/index.js';
import { action } from './actions.js';
import { buildUpreducer } from './buildUpreducer.js';
import {
    buildMiddleware,
    augmentMiddlewareApi,
} from './buildMiddleware/index.js';

/**
 * `Updux` is a way to minimize and simplify the boilerplate associated with the
 * creation of a `Redux` store. It takes a shorthand configuration
 * object, and generates the appropriate reducer, actions, middleware, etc.
 * In true `Redux`-like fashion, upduxes can be made of sub-upduxes (`subduxes` for short) for different slices of the root state.
 */
export class Updux {
    /** @type { unknown } */
    #initial = {};
    #subduxes = {};

    /** @type Record<string,Function> */
    #actions = {};
    #selectors = {};
    #mutations = {};
    #effects = [];
    #subscriptions = [];
    #splatSelector = undefined;
    #splatReaction = undefined;

    constructor(config) {
        this.#initial = config.initial ?? {};
        this.#subduxes = config.subduxes ?? {};

        if (config.subduxes) {
            this.#subduxes = mapValues(config.subduxes, (sub) =>
                sub instanceof Updux ? sub : new Updux(sub)
            );
        }

        if (config.actions) {
            for (const [type, actionArg] of Object.entries(config.actions)) {
                if (typeof actionArg === 'function' && actionArg.type) {
                    this.#actions[type] = actionArg;
                } else {
                    this.#actions[type] = action(type, actionArg);
                }
            }
        }

        this.#selectors = config.selectors ?? {};

        this.#mutations = config.mutations ?? {};

        this.#splatSelector = config.splatSelector;

        Object.keys(this.#mutations)
            .filter((action) => action !== '*')
            .filter((action) => !this.actions.hasOwnProperty(action))
            .forEach((action) => {
                throw new Error(`action '${action}' is not defined`);
            });

        if (config.effects) {
            this.#effects = Object.entries(config.effects);
        }

        this.#subscriptions = config.subscriptions ?? [];

        this.#splatReaction = config.splatReaction;
    }

    #memoInitial = moize(buildInitial);
    #memoActions = moize(buildActions);
    #memoSelectors = moize(buildSelectors);
    #memoUpreducer = moize(buildUpreducer);
    #memoMiddleware = moize(buildMiddleware);

    get subscriptions() {
        return this.#subscriptions;
    }

    setSplatSelector(name, f) {
        this.#splatSelector = [name, f];
    }

    get middleware() {
        return this.#memoMiddleware(
            this.#effects,
            this.actions,
            this.selectors,
            this.#subduxes
        );
    }

    /** @member { unknown } */
    get initial() {
        return this.#memoInitial(this.#initial, this.#subduxes);
    }

    /**
     *   @return {Record<string,Function>}
     */
    get actions() {
        return this.#memoActions(this.#actions, this.#subduxes);
    }

    get selectors() {
        return this.#memoSelectors(
            this.#selectors,
            this.#splatSelector,
            this.#subduxes
        );
    }

    get upreducer() {
        return this.#memoUpreducer(
            this.initial,
            this.#mutations,
            this.#subduxes
        );
    }

    get reducer() {
        return (state, action) => this.upreducer(action)(state);
    }

    addSubscription(subscription) {
        this.#subscriptions = [...this.#subscriptions, subscription];
    }

    addAction(type, payloadFunc) {
        const theAction = action(type, payloadFunc);

        this.#actions = { ...this.#actions, [type]: theAction };

        return theAction;
    }

    addSelector(name, func) {
        this.#selectors = {
            ...this.#selectors,
            [name]: func,
        };
        return func;
    }

    addMutation(name, mutation) {
        if (typeof name === 'function') name = name.type;

        this.#mutations = {
            ...this.#mutations,
            [name]: mutation,
        };

        return this;
    }

    addEffect(action, effect) {
        this.#effects = [...this.#effects, [action, effect]];
    }

    splatSubscriber(store, inner, splatReaction) {
        const cache = {};

        return () => (state, previous, unsub) => {
            const cacheKeys = Object.keys(cache);

            const newKeys = difference(Object.keys(state), cacheKeys);

            for (const slice of newKeys) {
                let localStore = {
                    ...store,
                    getState: () => store.getState()[slice],
                };

                cache[slice] = [];

                if (typeof splatReaction === 'function') {
                    localStore = {
                        ...localStore,
                        ...splatReaction(localStore, slice),
                    };
                }

                const { unsub, subscriber, subscriberRaw } =
                    inner.subscribeAll(localStore);

                cache[slice].push({ unsub, subscriber, subscriberRaw });
                subscriber();
            }

            const deletedKeys = difference(cacheKeys, Object.keys(state));

            for (const deleted of deletedKeys) {
                for (const inner of cache[deleted]) {
                    inner.subscriber();
                    inner.unsub();
                }
                delete cache[deleted];
            }
        };
    }

    subscribeTo(store, subscription, setupArgs = []) {
        const localStore = augmentMiddlewareApi(
            {
                ...store,
                subscribe: (subscriber) =>
                    this.subscribeTo(store, () => subscriber),
            },
            this.actions,
            this.selectors
        );

        const subscriber = subscription(localStore, ...setupArgs);

        let previous;

        const memoSub = () => {
            const state = store.getState();
            if (state === previous) return;
            let p = previous;
            previous = state;
            subscriber(state, p, unsub);
        };

        let ret = store.subscribe(memoSub);
        const unsub = typeof ret === 'function' ? ret : ret.unsub;
        return {
            unsub,
            subscriber: memoSub,
            subscriberRaw: subscriber,
        };
    }

    subscribeAll(store) {
        let results = this.#subscriptions.map((sub) =>
            this.subscribeTo(store, sub)
        );

        for (const subdux in this.#subduxes) {
            if (subdux !== '*') {
                const localStore = {
                    ...store,
                    getState: () => get(store.getState(), subdux),
                };
                results.push(this.#subduxes[subdux].subscribeAll(localStore));
            }
        }

        if (this.#splatReaction) {
            results.push(
                this.subscribeTo(
                    store,
                    this.splatSubscriber(
                        store,
                        this.#subduxes['*'],
                        this.#splatReaction
                    )
                )
            );
        }

        return {
            unsub: () => results.forEach(({ unsub }) => unsub()),
            subscriber: () => results.forEach(({ subscriber }) => subscriber()),
            subscriberRaw: (...args) =>
                results.forEach(({ subscriberRaw }) => subscriberRaw(...args)),
        };
    }

    createStore(initial) {
        const store = reduxCreateStore(
            this.reducer,
            initial ?? this.initial,
            applyMiddleware(this.middleware)
        );

        store.actions = this.actions;

        store.selectors = this.selectors;

        merge(
            store.getState,
            mapValues(this.selectors, (selector) => {
                return (...args) => {
                    let result = selector(store.getState());

                    if (typeof result === 'function') return result(...args);

                    return result;
                };
            })
        );

        for (const action in this.actions) {
            store.dispatch[action] = (...args) => {
                return store.dispatch(this.actions[action](...args));
            };
        }

        this.subscribeAll(store);

        return store;
    }
}

const x = new Updux();
x.selectors;