This commit is contained in:
Yanick Champoux 2021-10-12 09:42:30 -04:00
parent 912ea85edc
commit d14eb08bf0
9 changed files with 489 additions and 89 deletions

View File

@ -64,7 +64,7 @@
"url": "https://github.com/yanick/updux/issues" "url": "https://github.com/yanick/updux/issues"
}, },
"homepage": "https://github.com/yanick/updux#readme", "homepage": "https://github.com/yanick/updux#readme",
"types": "./dist/index.d.ts", "types": "./types/index.d.ts",
"prettier": { "prettier": {
"tabWidth": 4, "tabWidth": 4,
"singleQuote": true, "singleQuote": true,

View File

@ -1,7 +1,8 @@
/* TODO change * for leftovers to +, change subscriptions to reactions */
import moize from 'moize'; import moize from 'moize';
import u from '@yanick/updeep'; import u from '@yanick/updeep';
import { createStore as reduxCreateStore, applyMiddleware } from 'redux'; import { createStore as reduxCreateStore, applyMiddleware } from 'redux';
import { map, mapValues } from 'lodash-es'; import { get, map, mapValues, merge, difference } from 'lodash-es';
import { buildInitial } from './buildInitial/index.js'; import { buildInitial } from './buildInitial/index.js';
import { buildActions } from './buildActions/index.js'; import { buildActions } from './buildActions/index.js';
@ -13,56 +14,6 @@ import {
augmentMiddlewareApi, augmentMiddlewareApi,
} from './buildMiddleware/index.js'; } from './buildMiddleware/index.js';
function _subscribeToStore(store, subscriptions) {
for (const sub of subscriptions) {
const subscriber = sub({
...store,
subscribe(subscriber) {
let previous;
const unsub = store.subscribe(() => {
const state = store.getState();
if (state === previous) return;
let p = previous;
previous = state;
subscriber(state, p, unsub);
});
},
});
let unsub = store.subscribe(() => subscriber(store.getState(), unsub));
}
}
const sliceSubscriber = (slice, subdux) => (subscription) => (store) => {
let localStore = augmentMiddlewareApi(
{
...store,
getState: () => store.getState()[slice],
},
subdux.actions,
subdux.selectors
);
return (state, previous, unsub) =>
subscription(localStore)(
state[slice],
previous && previous[slice],
unsub
);
};
const memoizeSubscription = (subscription) => (store) => {
let previous = undefined;
const subscriber = subscription(store);
return (state, unsub) => {
if (state === previous) return;
let p = previous;
previous = state;
subscriber(state, p, unsub);
};
};
/** /**
* @public * @public
* `Updux` is a way to minimize and simplify the boilerplate associated with the * `Updux` is a way to minimize and simplify the boilerplate associated with the
@ -71,6 +22,7 @@ const memoizeSubscription = (subscription) => (store) => {
* In true `Redux`-like fashion, upduxes can be made of sub-upduxes (`subduxes` for short) for different slices of the root state. * In true `Redux`-like fashion, upduxes can be made of sub-upduxes (`subduxes` for short) for different slices of the root state.
*/ */
export class Updux { export class Updux {
/** @type { unknown } */
#initial = {}; #initial = {};
#subduxes = {}; #subduxes = {};
@ -80,10 +32,19 @@ export class Updux {
#mutations = {}; #mutations = {};
#effects = []; #effects = [];
#subscriptions = []; #subscriptions = [];
#splatSelector = undefined;
#splatReaction = undefined;
constructor(config) { constructor(config) {
this.#initial = config.initial ?? {}; this.#initial = config.initial ?? {};
this.#subduxes = config.subduxes ?? {}; this.#subduxes = config.subduxes ?? {};
if (config.subduxes) {
this.#subduxes = mapValues(config.subduxes, (sub) =>
sub instanceof Updux ? sub : new Updux(sub)
);
}
if (config.actions) { if (config.actions) {
for (const [type, actionArg] of Object.entries(config.actions)) { for (const [type, actionArg] of Object.entries(config.actions)) {
if (typeof actionArg === 'function' && actionArg.type) { if (typeof actionArg === 'function' && actionArg.type) {
@ -98,6 +59,8 @@ export class Updux {
this.#mutations = config.mutations ?? {}; this.#mutations = config.mutations ?? {};
this.#splatSelector = config.splatSelector;
Object.keys(this.#mutations) Object.keys(this.#mutations)
.filter((action) => action !== '*') .filter((action) => action !== '*')
.filter((action) => !this.actions.hasOwnProperty(action)) .filter((action) => !this.actions.hasOwnProperty(action))
@ -110,6 +73,8 @@ export class Updux {
} }
this.#subscriptions = config.subscriptions ?? []; this.#subscriptions = config.subscriptions ?? [];
this.#splatReaction = config.splatReaction;
} }
#memoInitial = moize(buildInitial); #memoInitial = moize(buildInitial);
@ -122,6 +87,10 @@ export class Updux {
return this.#subscriptions; return this.#subscriptions;
} }
setSplatSelector(name, f) {
this.#splatSelector = [name, f];
}
get middleware() { get middleware() {
return this.#memoMiddleware( return this.#memoMiddleware(
this.#effects, this.#effects,
@ -131,6 +100,7 @@ export class Updux {
); );
} }
/** @return { import('./Updux').What } */
get initial() { get initial() {
return this.#memoInitial(this.#initial, this.#subduxes); return this.#memoInitial(this.#initial, this.#subduxes);
} }
@ -143,7 +113,11 @@ export class Updux {
} }
get selectors() { get selectors() {
return this.#memoSelectors(this.#selectors, this.#subduxes); return this.#memoSelectors(
this.#selectors,
this.#splatSelector,
this.#subduxes
);
} }
get upreducer() { get upreducer() {
@ -193,7 +167,49 @@ export class Updux {
this.#effects = [...this.#effects, [action, effect]]; this.#effects = [...this.#effects, [action, effect]];
} }
subscribeTo(store, subscription) { 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( const localStore = augmentMiddlewareApi(
{ {
...store, ...store,
@ -204,37 +220,86 @@ export class Updux {
this.selectors this.selectors
); );
const subscriber = subscription(localStore); const subscriber = subscription(localStore, ...setupArgs);
let previous; let previous;
const unsub = store.subscribe(() => { const memoSub = () => {
const state = store.getState(); const state = store.getState();
if (state === previous) return; if (state === previous) return;
let p = previous; let p = previous;
previous = state; previous = state;
subscriber(state, p, unsub); subscriber(state, p, unsub);
}); };
let ret = store.subscribe(memoSub);
const unsub = typeof ret === 'function' ? ret : ret.unsub;
return {
unsub,
subscriber: memoSub,
subscriberRaw: subscriber,
};
} }
createStore() { 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( const store = reduxCreateStore(
this.reducer, this.reducer,
this.initial, initial ?? this.initial,
applyMiddleware(this.middleware) applyMiddleware(this.middleware)
); );
store.actions = this.actions; store.actions = this.actions;
store.selectors = mapValues(this.selectors, (selector) => { store.selectors = this.selectors;
return (...args) => {
let result = selector(store.getState());
if (typeof result === 'function') return result(...args); merge(
store.getState,
mapValues(this.selectors, (selector) => {
return (...args) => {
let result = selector(store.getState());
return result; if (typeof result === 'function') return result(...args);
};
}); return result;
};
})
);
for (const action in this.actions) { for (const action in this.actions) {
store.dispatch[action] = (...args) => { store.dispatch[action] = (...args) => {
@ -242,18 +307,11 @@ export class Updux {
}; };
} }
this.#subscriptions.forEach((sub) => this.subscribeTo(store, sub)); this.subscribeAll(store);
for (const subdux in this.#subduxes) {
const localStore = {
...store,
getState: () => store.getState()[subdux],
};
for (const subscription of this.#subduxes[subdux].subscriptions) {
this.#subduxes[subdux].subscribeTo(localStore, subscription);
}
}
return store; return store;
} }
} }
const x = new Updux();
x.selectors;

View File

@ -74,7 +74,7 @@ test('basic selectors', async (t) => {
getBar: ({ bar }) => bar, getBar: ({ bar }) => bar,
}, },
}); });
dux.addSelector('getFoo', (state) => state.foo); dux.addSelector('getFoo', ({ foo }) => foo);
dux.addSelector( dux.addSelector(
'getAdd', 'getAdd',
({ foo }) => ({ foo }) =>
@ -91,9 +91,9 @@ test('basic selectors', async (t) => {
const store = dux.createStore(); const store = dux.createStore();
t.equal(store.selectors.getFoo(), 1); t.equal(store.getState.getFoo(), 1);
t.equal(store.selectors.getQuux(), 3); t.equal(store.getState.getQuux(), 3);
t.equal(store.selectors.getAdd(7), 8); t.equal(store.getState.getAdd(7), 8);
}); });
test('mutations', async (t) => { test('mutations', async (t) => {

View File

@ -7,6 +7,8 @@ export function buildInitial(initial, subduxes = {}) {
"can't have subduxes on a dux which state is not an object" "can't have subduxes on a dux which state is not an object"
); );
if (Object.keys(subduxes).length === 1 && subduxes['*']) return initial;
const subInitial = mapValues(subduxes, ({ initial }, key) => const subInitial = mapValues(subduxes, ({ initial }, key) =>
key === '*' ? [] : initial key === '*' ? [] : initial
); );

View File

@ -69,7 +69,7 @@ export function buildMiddleware(
sub = {} sub = {}
) { ) {
let inner = map(sub, ({ middleware }, slice) => let inner = map(sub, ({ middleware }, slice) =>
middleware ? sliceMw(slice, middleware) : undefined slice !== '*' && middleware ? sliceMw(slice, middleware) : undefined
).filter((x) => x); ).filter((x) => x);
const local = effects.map((effect) => const local = effects.map((effect) =>

View File

@ -1,11 +1,30 @@
import { map, mapValues, merge } from 'lodash-es'; import { map, mapValues, merge } from 'lodash-es';
export function buildSelectors(localSelectors, subduxes) { export function buildSelectors(localSelectors, splatSelector, subduxes) {
const subSelectors = map(subduxes, ({ selectors }, slice) => { const subSelectors = map(subduxes, ({ selectors }, slice) => {
if (!selectors) return {}; if (!selectors) return {};
if (slice === '*') return {};
return mapValues(selectors, (func) => (state) => func(state[slice])); return mapValues(selectors, (func) => (state) => func(state[slice]));
}); });
return merge({}, ...subSelectors, localSelectors); let splat = {};
if (splatSelector) {
splat[splatSelector[0]] =
(state) =>
(...args) => {
const value = splatSelector[1](state)(...args);
const res = () => value;
return merge(
res,
mapValues(
subduxes['*'].selectors,
(selector) => () => selector(value)
)
);
};
}
return merge({}, ...subSelectors, localSelectors, splat);
} }

View File

@ -11,11 +11,19 @@ export function buildUpreducer(initial, mutations, subduxes = {}) {
let newState = state ?? initial; let newState = state ?? initial;
if (subReducers) { if (subReducers) {
const update = mapValues(subReducers, (upReducer) => if (subduxes['*']) {
upReducer(action) newState = u.updateIn(
); '*',
subduxes['*'].upreducer(action),
newState
);
} else {
const update = mapValues(subReducers, (upReducer) =>
upReducer(action)
);
newState = u(update, newState); newState = u(update, newState);
}
} }
const a = mutations[action.type] || mutations['*']; const a = mutations[action.type] || mutations['*'];

314
src/splat.test.js Normal file
View File

@ -0,0 +1,314 @@
import { test } from 'tap';
import sinon from 'sinon';
import { difference, omit } from 'lodash-es';
import { Updux } from './Updux.js';
test('initial', async (t) => {
const dux = new Updux({
initial: {},
subduxes: {
'*': {
initial: { a: 1 },
},
},
});
t.same(dux.initial, {});
});
test('actions', async (t) => {
const dux = new Updux({
initial: {},
subduxes: {
'*': {
initial: { a: 1 },
actions: { foo: null },
},
},
});
t.type(dux.actions.foo, 'function');
});
test('selectors', async (t) => {
const dux = new Updux({
initial: {},
subduxes: {
'*': {
initial: { a: 1 },
selectors: {
getA: ({ a }) => a,
},
},
},
});
t.same(dux.selectors, {});
const getInner = (state) => (index) => state[index];
dux.setSplatSelector('getInner', getInner);
t.type(dux.selectors.getInner, 'function');
t.same(
dux.selectors.getInner({
one: { a: 1 },
two: { a: 2 },
})('one')(),
{ a: 1 }
);
t.same(
dux.selectors
.getInner({
one: { a: 1 },
two: { a: 2 },
})('one')
.getA(),
1
);
// and now with the store
const store = dux.createStore({
one: { a: 1 },
two: { a: 2 },
});
t.same(store.getState.getInner('two')(), { a: 2 });
t.same(store.getState.getInner('two').getA(), 2);
});
test('splat middleware', async (t) => {
const snitch = sinon.fake(() => true);
const dux = new Updux({
initial: {},
subduxes: {
'*': {
initial: { a: 1 },
actions: { foo: null },
effects: {
foo: (api) => (next) => (action) => {
snitch();
next(action);
},
},
},
},
});
const store = dux.createStore({ one: { a: 1 }, two: { a: 2 } });
store.dispatch.foo();
t.notOk(snitch.called);
});
test('splat subscriptions', async (t) => {
const snitch = sinon.fake(() => true);
const dux = new Updux({
initial: {},
subduxes: {
'*': {
initial: { a: 1 },
actions: { foo: null },
mutations: {
foo: (id) => (state) =>
state.a === i ? { ...state, b: 1 } : state,
},
subscriptions: [
(store) => (state, previous, unsub) => {
snitch(state);
},
],
},
},
});
const store = dux.createStore({ one: { a: 1 }, two: { a: 2 } });
store.dispatch({ type: 'noop' });
t.notOk(snitch.called);
});
test('splat subscriptions, more', async (t) => {
const snitch = sinon.fake(() => true);
const inner = new Updux({
initial: { a: 1 },
actions: { foo: null, incAll: null },
mutations: {
foo: (id) => (state) => state.a === id ? { ...state, b: 1 } : state,
incAll: () => (state) => ({ ...state, a: state.a + 1 }),
},
subscriptions: [() => snitch],
});
const dux = new Updux({
initial: {},
actions: {
delete: null,
newEntry: null,
},
mutations: {
delete: (id) => (state) => omit(state, id),
newEntry: (name) => (state) => ({ ...state, [name]: { a: 10 } }),
},
splatReaction: 'whatev',
subduxes: {
'*': inner,
},
});
const store = dux.createStore({ one: { a: 1 }, two: { a: 2 } });
store.dispatch({ type: 'noop' });
t.equal(snitch.callCount, 2);
t.same(snitch.firstCall.args[0], { a: 1 });
t.same(snitch.secondCall.args[0], { a: 2 });
snitch.resetHistory();
store.dispatch.foo(2);
t.equal(snitch.callCount, 1);
t.same(snitch.firstCall.args[0], { a: 2, b: 1 });
snitch.resetHistory();
store.dispatch.delete('one');
t.same(store.getState(), {
two: { a: 2, b: 1 },
});
t.equal(snitch.callCount, 1);
t.ok(
snitch.calledWithMatch(
undefined,
{ a: 1 },
sinon.match.typeOf('function')
)
);
await t.test('only one subscriber left', async (t) => {
snitch.resetHistory();
store.dispatch.incAll();
t.equal(snitch.callCount, 1);
t.ok(
snitch.calledWithMatch(
{ a: 3, b: 1 },
{ a: 2, b: 1 },
sinon.match.typeOf('function')
)
);
});
await t.test('new entry gets subscribed', async (t) => {
snitch.resetHistory();
store.dispatch.newEntry('newbie');
t.equal(snitch.callCount, 1);
t.ok(
snitch.calledWithMatch(
{ a: 10 },
undefined,
sinon.match.typeOf('function')
)
);
});
});
test('many levels down', { only: false }, async (t) => {
const snitch = sinon.fake(() => true);
const dux = new Updux({
splatReaction: 'potato',
actions: { remove: null },
mutations: {
remove: () => (state) => ({}),
},
subduxes: {
'*': {
subduxes: {
a: {
initial: 1,
actions: {
add: null,
},
mutations: {
add: () => (x) => x + 1,
},
subscriptions: [
(store) => (state) => snitch(state, store),
],
},
},
},
},
});
const store = dux.createStore({ one: { a: 1 } });
store.dispatch({ type: 'foo' });
t.ok(snitch.calledOnce);
t.same(snitch.firstCall.firstArg, 1);
snitch.resetHistory();
store.dispatch.remove();
t.ok(snitch.calledOnce);
t.same(snitch.firstCall.firstArg, undefined);
});
test('inherit info via the store', async (t) => {
const snitch = sinon.fake(() => true);
const splatSnitch = sinon.fake(() => true);
const dux = new Updux({
splatReaction: (store, key) => {
store.itemId = key;
splatSnitch();
return { itemId: key };
},
subduxes: {
'*': {
subduxes: {
a: {
initial: 1,
actions: {
add: null,
},
mutations: {
add: () => (x) => x + 1,
},
subscriptions: [
(store) => (state) => snitch(state, store.itemId),
],
},
},
},
},
});
const store = dux.createStore({
one: { a: 1 },
two: { a: 2 },
});
store.dispatch({ type: 'noop' });
t.ok(splatSnitch.calledTwice);
t.ok(snitch.calledTwice);
t.ok(snitch.calledWithMatch(1, 'one'));
t.ok(snitch.calledWithMatch(2, 'two'));
});

View File

@ -119,7 +119,6 @@ tap.test('subscription within subduxes', { todo: false }, async (t) => {
}, },
subscriptions: [ subscriptions: [
(store) => (state, previous, unsub) => { (store) => (state, previous, unsub) => {
console.log(state, previous);
if (!previous) return; if (!previous) return;
store.subscribe(innerState); store.subscribe(innerState);
unsub(); unsub();