import R from 'remeda'; import u from 'updeep'; import { createStore as reduxCreateStore, applyMiddleware } from 'redux'; import { buildSelectors } from './selectors.js'; import { buildUpreducer } from './upreducer.js'; import { buildMiddleware, augmentMiddlewareApi } from './middleware.js'; import { action, isActionGen } from './actions.js'; /** * Updux configuration object * @typedef {Object} Updux config * @property {Object} actions - Actions to be made available to the updux. */ export class Updux { #name = 'unknown'; #localInitial = {}; #subduxes = {}; #actions; #mutations = {}; #config = {}; #selectors = {}; #effects = []; #localReactions = []; #middlewareWrapper; #splatReactionMapper; constructor(config = {}) { this.#config = config; this.#name = config.name || 'unknown'; this.#splatReactionMapper = config.splatReactionMapper; this.#middlewareWrapper = config.middlewareWrapper; this.#localInitial = config.initial; this.#subduxes = config.subduxes ?? {}; this.#actions = R.mapValues(config.actions ?? {}, (arg, name) => isActionGen(arg) ? arg : action(name, arg), ); Object.entries(this.#subduxes).forEach(([slice, sub]) => this.#addSubduxActions(slice, sub), ); Object.entries(config.mutations ?? {}).forEach((args) => this.setMutation(...args), ); this.#selectors = buildSelectors( config.selectors, config.findSelectors, this.#subduxes, ); if (Array.isArray(config.effects)) { this.#effects = config.effects; } else if (R.isObject(config.effects)) { this.#effects = Object.entries(config.effects); } this.#localReactions = config.reactions ?? []; } get name() { return this.#name; } #addSubduxActions(_slice, subdux) { if (!subdux.actions) return; // TODO action 'blah' defined multiple times: Object.entries(subdux.actions).forEach(([action, gen]) => { if (this.#actions[action]) { if (this.#actions[action] === gen) return; throw new Error(`action '${action}' already defined`); } this.#actions[action] = gen; }); } get selectors() { return this.#selectors; } get actions() { return this.#actions; } get initial() { if (Object.keys(this.#subduxes).length === 0) return this.#localInitial ?? {}; if (this.#subduxes['*']) { if (this.#localInitial) return this.#localInitial; return []; } return Object.assign( {}, this.#localInitial ?? {}, R.mapValues(this.#subduxes, ({ initial }) => initial), ); } get reducer() { return (state, action) => this.upreducer(action)(state); } get upreducer() { return buildUpreducer(this.#mutations, this.#subduxes); } /** * * @param {string | Function} action - Action triggering the mutation. If * the action is a string, it has to have been previously declared for this * updux, but if it's a function generator, it'll be automatically added to the * updux if not already present (the idea being that making a typo on a string * is easy, but passing a wrong function very more unlikely). * @param {Function} mutation - Mutating function. * @param {bool} terminal - If true, subduxes' mutations won't be invoked on * the action. * @return {void} */ setMutation(action, mutation, terminal = false) { // TODO option strict: false to make it okay to auto-create // the actions as strings? if (action.type) { if (!this.#actions[action.type]) { this.#actions[action.type] = action; } else if (this.#actions[action.type] !== action) { throw new Error( `action '${action.type}' not defined for this updux or definition is different`, ); } action = action.type; } if (!this.#actions[action] && action !== '*') { throw new Error(`action '${action}' is not defined`); } if (terminal) { const originalMutation = mutation; mutation = (...args) => originalMutation(...args); mutation.terminal = true; } this.#mutations[action] = mutation; } get mutations() { return this.#mutations; } get middleware() { return buildMiddleware( this.#effects, this.actions, this.selectors, this.#subduxes, this.#middlewareWrapper, ); } createStore(initial = undefined, enhancerGenerator = undefined) { const enhancer = (enhancerGenerator ?? applyMiddleware)( this.middleware, ); const store = reduxCreateStore( this.reducer, initial ?? this.initial, enhancer, ); store.actions = this.actions; store.selectors = this.selectors; Object.entries(this.selectors).forEach(([selector, fn]) => { store.getState[selector] = (...args) => { let result = fn(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; } addEffect(action, effect) { this.#effects = [...this.#effects, [action, effect]]; } addReaction(reaction) { this.#localReactions = [...this.#localReactions, reaction]; } augmentMiddlewareApi(api) { return augmentMiddlewareApi(api, this.actions, this.selectors); } subscribeTo(store, subscription) { const localStore = this.augmentMiddlewareApi({ ...store, subscribe: (subscriber) => this.subscribeTo(store, subscriber), // TODO not sure }); const subscriber = subscription(localStore); let previous; let unsub; const memoSub = () => { const state = store.getState(); if (state === previous) return; let p = previous; previous = state; subscriber(state, p, unsub); }; return store.subscribe(memoSub); } createSplatReaction() { const subdux = this.#subduxes['*']; const mapper = this.#splatReactionMapper; return (api) => { const cache = {}; return (state, previousState, unsubscribe) => { const gone = { ...cache }; // TODO assuming object here for (const key in state) { if (cache[key]) { delete gone[key]; } else { const dux = new Updux({ initial: null, actions: { update: null }, mutations: { update: (payload) => () => payload, }, }); const store = dux.createStore(); // TODO need to change the store to have the // subscribe pointing to the right slice? const context = { ...(api.context ?? {}), [subdux.name]: key, }; const unsub = subdux.subscribeAll({ ...store, context, }); cache[key] = { store, unsub }; } cache[key].store.dispatch.update(state[key]); } for (const key in gone) { cache[key].store.dispatch.update(null); cache[key].unsub(); delete cache[key]; } }; }; } subscribeSplatReaction(store) { return this.subscribeTo(store, this.createSplatReaction()); } subscribeAll(store) { let unsubs = this.#localReactions.map((sub) => this.subscribeTo(store, sub), ); if (this.#splatReactionMapper) { unsubs.push(this.subscribeSplatReaction(store)); } unsubs.push( ...Object.entries(this.#subduxes) .filter(([slice]) => slice !== '*') .map(([slice, subdux]) => { subdux.subscribeAll({ ...store, getState: () => store.getState()[slice], }); }), ); return () => unsubs.forEach((u) => u()); } } export const dux = (config) => new Updux(config);