/* TODO change * for leftovers to +, change subscriptions to reactions */ import moize from 'moize'; import u from '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, effectToMiddleware, } from './buildMiddleware/index.js'; import { AggregateDuxActions, AggregateDuxState, Dict, ItemsOf, Reducer, Upreducer, } from './types.js'; type Mutation = (payload:TAction['payload'], action:TAction) => (state: TState) => TState; /** * Configuration object typically passed to the constructor of the class Updux. */ export interface UpduxConfig< TState = any, TActions = {}, TSelectors = {}, TSubduxes = {} > { /** * Local initial state. * @default {} */ initial?: TState; /** * Subduxes to be merged to this dux. */ subduxes?: TSubduxes; /** * Local actions. */ actions?: TActions; /** * 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; /** * Wrapping function for the upreducer to provides full customization. * @example * // if an action has the 'dontDoIt' meta flag, don't do anything * const dux = new Updux({ * ..., * upreducerWrapper: (upreducer) => action => { * if( action?.meta?.dontDoIt ) return state => state; * return upreducer(action); * } * }) */ upreducerWrapper?: ( upreducer: Upreducer< AggregateDuxState, ItemsOf> > ) => Upreducer< AggregateDuxState, ItemsOf> >; middlewareWrapper?: Function; } export class Updux< TState extends any = {}, TActions extends object = {}, TSelectors = {}, TSubduxes extends object = {} > { /** @type { unknown } */ #initial = {}; #subduxes = {}; /** @type Record */ #actions = {}; #selectors = {}; #mutations = {}; #effects = []; #reactions = []; #mappedSelectors = undefined; #mappedReaction = undefined; #upreducerWrapper = undefined; #middlewareWrapper = 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 { const args = Array.isArray(actionArg) ? actionArg : [actionArg]; this.#actions[type] = action(type, ...args); } } } 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; this.#upreducerWrapper = config.upreducerWrapper; this.#middlewareWrapper = config.middlewareWrapper; } #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, this.#middlewareWrapper, this ); } setMiddlewareWrapper(wrapper: Function) { this.#middlewareWrapper = wrapper; } /** @member { unknown } */ get initial(): AggregateDuxState { return this.#memoInitial(this.#initial, this.#subduxes); } get actions(): AggregateDuxActions { return this.#memoActions(this.#actions, this.#subduxes) as any; } get selectors() { return this.#memoSelectors( this.#selectors, this.#mappedSelectors, this.#subduxes ); } get subduxes() { return this.#subduxes } get upreducer(): Upreducer< AggregateDuxState, ItemsOf> > { return this.#memoUpreducer( this.initial, this.#mutations, this.#subduxes, this.#upreducerWrapper ); } get reducer(): Reducer< AggregateDuxState, ItemsOf> > { return (state, action) => this.upreducer(action)(state); } addSubscription(subscription) { this.#reactions = [...this.#reactions, subscription]; } addReaction(reaction) { this.#reactions = [...this.#reactions, reaction]; } setAction(type, payloadFunc?: (...args: any) => any) { 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: TAction, mutation: Mutation, ReturnType[TAction]>>) { if (typeof name === 'function') name = name.type; this.#mutations = { ...this.#mutations, [name]: mutation, }; return mutation; } addEffect(action: TType, effect: E): E { this.#effects = [...this.#effects, [action, effect]]; return effect; } augmentMiddlewareApi(api) { return augmentMiddlewareApi(api, this.actions, this.selectors); } 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?: unknown, enhancerGenerator?: Function) { const enhancer = (enhancerGenerator ?? applyMiddleware)( this.middleware ); const store: { getState: Function & Record; dispatch: Function & Record; selectors: Record; actions: AggregateDuxActions; } = reduxCreateStore( this.reducer as any, initial ?? this.initial, enhancer ) 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 as any))); }; } this.subscribeAll(store); return store; } effectToMiddleware(effect) { return effectToMiddleware(effect, this.actions, this.selectors); } }