/* 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'; export class Updux { /** @type { unknown } */ #initial = {}; #subduxes = {}; /** @type Record */ #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.#splatSelector = config.splatSelector; this.#mutations = config.mutations ?? {}; 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} */ 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]; } setAction(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; } setMutation(name, mutation) { if (typeof name === 'function') name = name.type; this.#mutations = { ...this.#mutations, [name]: mutation, }; return mutation; } 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; } }