/* 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'; import { buildInitial } from './buildInitial'; import { buildActions } from './buildActions'; import { buildSelectors } from './buildSelectors'; import { action } from './actions'; import { buildUpreducer } from './buildUpreducer'; import { buildMiddleware, augmentMiddlewareApi, } from './buildMiddleware'; import { Dict } from './types'; /** * Configuration object typically passed to the constructor of the class Updux. */ export interface UpduxConfig { /** * Local initial state. * @default {} */ initial?: TState; /** * Subduxes to be merged to this dux. */ subduxes?: Dict; /** * Local actions. */ actions?: Record; /** * Local selectors. */ selectors?: Record; /** * Local mutations */ mutations?: Record; /** * Selectors to apply to the mapped subduxes. Only * applicable if the dux is a mapping dux. */ mappedSelectors?: Record; /** * Local effects. */ effects?: Record; /** * Local reactions. */ reactions?: Function[]; /** * If true, enables mapped reactions. Additionally, it can be * a reaction function, which will treated as a regular * reaction for the mapped dux. */ mappedReaction?: Function | boolean; } export class Updux { /** @type { unknown } */ #initial = {}; #subduxes = {}; /** @type Record */ #actions = {}; #selectors = {}; #mutations = {}; #effects = []; #reactions = []; #mappedSelectors = undefined; #mappedReaction = undefined; constructor(config: UpduxConfig) { 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.#mappedSelectors = config.mappedSelectors; 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.#reactions = config.reactions ?? []; this.#mappedReaction = config.mappedReaction; } #memoInitial = moize(buildInitial); #memoActions = moize(buildActions); #memoSelectors = moize(buildSelectors); #memoUpreducer = moize(buildUpreducer); #memoMiddleware = moize(buildMiddleware); setMappedSelector(name, f) { this.#mappedSelectors = { ...this.#mappedSelectors, [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.#mappedSelectors, 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.#reactions = [...this.#reactions, subscription]; } setAction(type, payloadFunc) { const theAction = action(type, payloadFunc); this.#actions = { ...this.#actions, [type]: theAction }; return theAction; } setSelector(name, func) { // TODO selector already exists? Complain! 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.#reactions.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.#mappedReaction) { results.push( this.subscribeTo( store, this.splatSubscriber( store, this.#subduxes['*'], this.#mappedReaction ) ) ); } return { unsub: () => results.forEach(({ unsub }) => unsub()), subscriber: () => results.forEach(({ subscriber }) => subscriber()), subscriberRaw: (...args) => results.forEach(({ subscriberRaw }) => subscriberRaw(...args)), }; } createStore(initial) { const store : { getState: Function, dispatch: Function, selectors: Record, actions: Record, } = reduxCreateStore( this.reducer, initial ?? this.initial, applyMiddleware(this.middleware) ) as any; 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; } }