updux/src/Updux.js

316 lines
9.1 KiB
JavaScript
Raw Permalink Normal View History

2022-08-25 23:43:07 +00:00
import R from 'remeda';
2022-08-28 16:47:24 +00:00
import u from 'updeep';
2022-08-29 14:32:52 +00:00
import { createStore as reduxCreateStore, applyMiddleware } from 'redux';
2022-08-25 23:43:07 +00:00
2022-08-26 14:37:17 +00:00
import { buildSelectors } from './selectors.js';
2022-08-28 16:47:24 +00:00
import { buildUpreducer } from './upreducer.js';
2022-08-30 18:13:59 +00:00
import { buildMiddleware, augmentMiddlewareApi } from './middleware.js';
2022-08-28 23:29:31 +00:00
import { action, isActionGen } from './actions.js';
2022-08-25 23:43:07 +00:00
2022-08-26 17:26:51 +00:00
/**
* Updux configuration object
* @typedef {Object} Updux config
* @property {Object} actions - Actions to be made available to the updux.
*/
2022-08-25 23:43:07 +00:00
export class Updux {
2022-09-06 16:54:23 +00:00
#name = 'unknown';
2022-08-25 23:43:07 +00:00
#localInitial = {};
#subduxes = {};
2022-08-26 00:06:52 +00:00
#actions;
2022-08-25 23:43:07 +00:00
#mutations = {};
#config = {};
2022-08-26 14:37:17 +00:00
#selectors = {};
2022-08-29 14:32:52 +00:00
#effects = [];
2022-08-30 18:13:59 +00:00
#localReactions = [];
2022-09-06 15:35:47 +00:00
#middlewareWrapper;
2022-09-06 16:54:23 +00:00
#splatReactionMapper;
2022-08-25 23:43:07 +00:00
constructor(config = {}) {
this.#config = config;
2022-09-06 15:35:47 +00:00
2022-09-06 16:54:23 +00:00
this.#name = config.name || 'unknown';
this.#splatReactionMapper = config.splatReactionMapper;
2022-09-06 15:35:47 +00:00
this.#middlewareWrapper = config.middlewareWrapper;
2022-08-30 14:55:16 +00:00
this.#localInitial = config.initial;
2022-08-25 23:43:07 +00:00
this.#subduxes = config.subduxes ?? {};
2022-08-26 00:06:52 +00:00
2022-08-26 19:30:03 +00:00
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),
2022-08-26 19:29:24 +00:00
);
2022-08-26 14:37:17 +00:00
2022-08-28 23:29:31 +00:00
Object.entries(config.mutations ?? {}).forEach((args) =>
this.setMutation(...args),
);
2022-08-28 16:47:24 +00:00
this.#selectors = buildSelectors(
config.selectors,
2022-08-30 14:09:24 +00:00
config.findSelectors,
2022-08-28 16:47:24 +00:00
this.#subduxes,
);
2022-08-29 14:32:52 +00:00
if (Array.isArray(config.effects)) {
this.#effects = config.effects;
} else if (R.isObject(config.effects)) {
this.#effects = Object.entries(config.effects);
}
2022-08-30 18:13:59 +00:00
this.#localReactions = config.reactions ?? [];
2022-08-26 18:41:22 +00:00
}
2022-09-06 16:54:23 +00:00
get name() {
return this.#name;
}
2022-08-26 18:41:22 +00:00
#addSubduxActions(_slice, subdux) {
2022-08-26 19:30:03 +00:00
if (!subdux.actions) return;
2022-08-26 18:41:22 +00:00
// TODO action 'blah' defined multiple times: <where>
2022-08-26 19:30:03 +00:00
Object.entries(subdux.actions).forEach(([action, gen]) => {
if (this.#actions[action]) {
if (this.#actions[action] === gen) return;
2022-08-26 18:41:22 +00:00
throw new Error(`action '${action}' already defined`);
}
this.#actions[action] = gen;
});
2022-08-25 23:43:07 +00:00
}
2022-08-26 14:37:17 +00:00
get selectors() {
return this.#selectors;
}
2022-08-25 23:43:07 +00:00
get actions() {
return this.#actions;
}
get initial() {
2022-08-30 18:16:29 +00:00
if (Object.keys(this.#subduxes).length === 0)
return this.#localInitial ?? {};
2022-08-30 14:55:16 +00:00
2022-08-30 18:16:29 +00:00
if (this.#subduxes['*']) {
if (this.#localInitial) return this.#localInitial;
2022-08-30 14:55:16 +00:00
return [];
}
2022-08-25 23:43:07 +00:00
return Object.assign(
{},
2022-08-30 14:55:16 +00:00
this.#localInitial ?? {},
2022-08-25 23:43:07 +00:00
R.mapValues(this.#subduxes, ({ initial }) => initial),
);
}
get reducer() {
2022-08-26 00:06:52 +00:00
return (state, action) => this.upreducer(action)(state);
2022-08-25 23:43:07 +00:00
}
get upreducer() {
2022-08-28 16:47:24 +00:00
return buildUpreducer(this.#mutations, this.#subduxes);
}
2022-08-25 23:43:07 +00:00
2022-08-28 16:47:24 +00:00
/**
*
* @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.
2022-09-02 14:40:34 +00:00
* @param {bool} terminal - If true, subduxes' mutations won't be invoked on
* the action.
2022-08-28 16:47:24 +00:00
* @return {void}
*/
2022-08-30 20:10:38 +00:00
setMutation(action, mutation, terminal = false) {
2022-08-28 16:47:24 +00:00
// 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`,
);
2022-08-25 23:43:07 +00:00
}
2022-08-28 16:47:24 +00:00
action = action.type;
}
2022-09-02 14:40:34 +00:00
if (!this.#actions[action] && action !== '*') {
2022-08-28 16:47:24 +00:00
throw new Error(`action '${action}' is not defined`);
}
2022-09-02 14:40:34 +00:00
if (terminal) {
2022-08-30 20:10:38 +00:00
const originalMutation = mutation;
mutation = (...args) => originalMutation(...args);
mutation.terminal = true;
}
2022-08-28 16:47:24 +00:00
this.#mutations[action] = mutation;
2022-08-25 23:43:07 +00:00
}
2022-08-28 16:47:24 +00:00
get mutations() {
return this.#mutations;
2022-08-25 23:43:07 +00:00
}
2022-08-26 17:03:33 +00:00
2022-08-29 14:32:52 +00:00
get middleware() {
return buildMiddleware(
this.#effects,
this.actions,
this.selectors,
2022-09-06 15:35:47 +00:00
this.#subduxes,
this.#middlewareWrapper,
2022-08-29 14:32:52 +00:00
);
}
2022-08-26 17:03:33 +00:00
createStore(initial = undefined, enhancerGenerator = undefined) {
2022-08-29 14:32:52 +00:00
const enhancer = (enhancerGenerator ?? applyMiddleware)(
this.middleware,
);
2022-08-26 17:03:33 +00:00
const store = reduxCreateStore(
this.reducer,
initial ?? this.initial,
2022-08-29 14:32:52 +00:00
enhancer,
2022-08-26 17:03:33 +00:00
);
store.actions = this.actions;
2022-08-29 15:56:30 +00:00
store.selectors = this.selectors;
2022-08-26 17:03:33 +00:00
2022-08-29 15:56:30 +00:00
Object.entries(this.selectors).forEach(([selector, fn]) => {
store.getState[selector] = (...args) => {
let result = fn(store.getState());
2022-08-26 17:03:33 +00:00
2022-08-29 15:56:30 +00:00
if (typeof result === 'function') return result(...args);
2022-08-26 17:03:33 +00:00
2022-08-29 15:56:30 +00:00
return result;
};
});
2022-08-26 17:03:33 +00:00
for (const action in this.actions) {
store.dispatch[action] = (...args) => {
2022-08-26 19:51:00 +00:00
return store.dispatch(this.actions[action](...args));
2022-08-26 17:03:33 +00:00
};
}
2022-08-30 18:13:59 +00:00
this.subscribeAll(store);
2022-08-26 17:03:33 +00:00
return store;
}
2022-08-29 14:32:52 +00:00
addEffect(action, effect) {
this.#effects = [...this.#effects, [action, effect]];
}
2022-08-30 18:13:59 +00:00
2022-08-30 18:16:29 +00:00
addReaction(reaction) {
this.#localReactions = [...this.#localReactions, reaction];
2022-08-30 18:13:59 +00:00
}
augmentMiddlewareApi(api) {
return augmentMiddlewareApi(api, this.actions, this.selectors);
}
2022-08-30 18:13:59 +00:00
subscribeTo(store, subscription) {
2022-09-06 16:54:23 +00:00
const localStore = this.augmentMiddlewareApi({
...store,
subscribe: (subscriber) => this.subscribeTo(store, subscriber), // TODO not sure
});
2022-08-30 18:13:59 +00:00
const subscriber = subscription(localStore);
let previous;
2022-09-06 16:54:23 +00:00
let unsub;
2022-08-30 18:13:59 +00:00
const memoSub = () => {
const state = store.getState();
if (state === previous) return;
let p = previous;
previous = state;
subscriber(state, p, unsub);
};
2022-09-06 16:54:23 +00:00
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];
}
};
2022-08-30 18:13:59 +00:00
};
}
2022-09-06 16:54:23 +00:00
subscribeSplatReaction(store) {
return this.subscribeTo(store, this.createSplatReaction());
}
2022-08-30 18:13:59 +00:00
subscribeAll(store) {
2022-09-06 16:54:23 +00:00
let unsubs = this.#localReactions.map((sub) =>
2022-08-30 18:16:29 +00:00
this.subscribeTo(store, sub),
2022-08-30 18:13:59 +00:00
);
2022-09-06 16:54:23 +00:00
if (this.#splatReactionMapper) {
unsubs.push(this.subscribeSplatReaction(store));
2022-08-30 18:13:59 +00:00
}
2022-09-06 16:54:23 +00:00
unsubs.push(
...Object.entries(this.#subduxes)
.filter(([slice]) => slice !== '*')
.map(([slice, subdux]) => {
subdux.subscribeAll({
...store,
getState: () => store.getState()[slice],
});
}),
);
return () => unsubs.forEach((u) => u());
2022-08-30 18:13:59 +00:00
}
2022-08-25 23:43:07 +00:00
}
2022-08-26 14:37:17 +00:00
export const dux = (config) => new Updux(config);