From d14eb08bf0866b31e213687efa83ac8a8c6a7365 Mon Sep 17 00:00:00 2001 From: Yanick Champoux Date: Tue, 12 Oct 2021 09:42:30 -0400 Subject: [PATCH] splat --- package.json | 2 +- src/Updux.js | 210 ++++++++++++++--------- src/Updux.test.js | 8 +- src/buildInitial/index.js | 2 + src/buildMiddleware/index.js | 2 +- src/buildSelectors/index.js | 23 ++- src/buildUpreducer.js | 16 +- src/splat.test.js | 314 +++++++++++++++++++++++++++++++++++ src/subscriptions.test.js | 1 - 9 files changed, 489 insertions(+), 89 deletions(-) create mode 100644 src/splat.test.js diff --git a/package.json b/package.json index 0da9286..da95d6b 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "url": "https://github.com/yanick/updux/issues" }, "homepage": "https://github.com/yanick/updux#readme", - "types": "./dist/index.d.ts", + "types": "./types/index.d.ts", "prettier": { "tabWidth": 4, "singleQuote": true, diff --git a/src/Updux.js b/src/Updux.js index 4c1b65f..49d3517 100644 --- a/src/Updux.js +++ b/src/Updux.js @@ -1,7 +1,8 @@ +/* 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 { map, mapValues } from 'lodash-es'; +import { get, map, mapValues, merge, difference } from 'lodash-es'; import { buildInitial } from './buildInitial/index.js'; import { buildActions } from './buildActions/index.js'; @@ -13,56 +14,6 @@ import { augmentMiddlewareApi, } 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 * `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. */ export class Updux { + /** @type { unknown } */ #initial = {}; #subduxes = {}; @@ -80,10 +32,19 @@ export class Updux { #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) { @@ -98,6 +59,8 @@ export class Updux { this.#mutations = config.mutations ?? {}; + this.#splatSelector = config.splatSelector; + Object.keys(this.#mutations) .filter((action) => action !== '*') .filter((action) => !this.actions.hasOwnProperty(action)) @@ -110,6 +73,8 @@ export class Updux { } this.#subscriptions = config.subscriptions ?? []; + + this.#splatReaction = config.splatReaction; } #memoInitial = moize(buildInitial); @@ -122,6 +87,10 @@ export class Updux { return this.#subscriptions; } + setSplatSelector(name, f) { + this.#splatSelector = [name, f]; + } + get middleware() { return this.#memoMiddleware( this.#effects, @@ -131,6 +100,7 @@ export class Updux { ); } + /** @return { import('./Updux').What } */ get initial() { return this.#memoInitial(this.#initial, this.#subduxes); } @@ -143,7 +113,11 @@ export class Updux { } get selectors() { - return this.#memoSelectors(this.#selectors, this.#subduxes); + return this.#memoSelectors( + this.#selectors, + this.#splatSelector, + this.#subduxes + ); } get upreducer() { @@ -193,7 +167,49 @@ export class Updux { 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( { ...store, @@ -204,37 +220,86 @@ export class Updux { this.selectors ); - const subscriber = subscription(localStore); + const subscriber = subscription(localStore, ...setupArgs); let previous; - const unsub = store.subscribe(() => { + 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, + }; } - 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( this.reducer, - this.initial, + initial ?? this.initial, applyMiddleware(this.middleware) ); store.actions = this.actions; - store.selectors = mapValues(this.selectors, (selector) => { - return (...args) => { - let result = selector(store.getState()); + store.selectors = this.selectors; - 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) { store.dispatch[action] = (...args) => { @@ -242,18 +307,11 @@ export class Updux { }; } - this.#subscriptions.forEach((sub) => this.subscribeTo(store, sub)); - - 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); - } - } + this.subscribeAll(store); return store; } } + +const x = new Updux(); +x.selectors; diff --git a/src/Updux.test.js b/src/Updux.test.js index c2b552a..c8e1126 100644 --- a/src/Updux.test.js +++ b/src/Updux.test.js @@ -74,7 +74,7 @@ test('basic selectors', async (t) => { getBar: ({ bar }) => bar, }, }); - dux.addSelector('getFoo', (state) => state.foo); + dux.addSelector('getFoo', ({ foo }) => foo); dux.addSelector( 'getAdd', ({ foo }) => @@ -91,9 +91,9 @@ test('basic selectors', async (t) => { const store = dux.createStore(); - t.equal(store.selectors.getFoo(), 1); - t.equal(store.selectors.getQuux(), 3); - t.equal(store.selectors.getAdd(7), 8); + t.equal(store.getState.getFoo(), 1); + t.equal(store.getState.getQuux(), 3); + t.equal(store.getState.getAdd(7), 8); }); test('mutations', async (t) => { diff --git a/src/buildInitial/index.js b/src/buildInitial/index.js index b387363..9f2b68b 100644 --- a/src/buildInitial/index.js +++ b/src/buildInitial/index.js @@ -7,6 +7,8 @@ export function buildInitial(initial, subduxes = {}) { "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) => key === '*' ? [] : initial ); diff --git a/src/buildMiddleware/index.js b/src/buildMiddleware/index.js index af4697f..e941244 100644 --- a/src/buildMiddleware/index.js +++ b/src/buildMiddleware/index.js @@ -69,7 +69,7 @@ export function buildMiddleware( sub = {} ) { let inner = map(sub, ({ middleware }, slice) => - middleware ? sliceMw(slice, middleware) : undefined + slice !== '*' && middleware ? sliceMw(slice, middleware) : undefined ).filter((x) => x); const local = effects.map((effect) => diff --git a/src/buildSelectors/index.js b/src/buildSelectors/index.js index b2bbc62..98c9acd 100644 --- a/src/buildSelectors/index.js +++ b/src/buildSelectors/index.js @@ -1,11 +1,30 @@ import { map, mapValues, merge } from 'lodash-es'; -export function buildSelectors(localSelectors, subduxes) { +export function buildSelectors(localSelectors, splatSelector, subduxes) { const subSelectors = map(subduxes, ({ selectors }, slice) => { if (!selectors) return {}; + if (slice === '*') return {}; 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); } diff --git a/src/buildUpreducer.js b/src/buildUpreducer.js index e2b26b3..71ccada 100644 --- a/src/buildUpreducer.js +++ b/src/buildUpreducer.js @@ -11,11 +11,19 @@ export function buildUpreducer(initial, mutations, subduxes = {}) { let newState = state ?? initial; if (subReducers) { - const update = mapValues(subReducers, (upReducer) => - upReducer(action) - ); + if (subduxes['*']) { + 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['*']; diff --git a/src/splat.test.js b/src/splat.test.js new file mode 100644 index 0000000..1dc7842 --- /dev/null +++ b/src/splat.test.js @@ -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')); +}); diff --git a/src/subscriptions.test.js b/src/subscriptions.test.js index 6c09023..185fd32 100644 --- a/src/subscriptions.test.js +++ b/src/subscriptions.test.js @@ -119,7 +119,6 @@ tap.test('subscription within subduxes', { todo: false }, async (t) => { }, subscriptions: [ (store) => (state, previous, unsub) => { - console.log(state, previous); if (!previous) return; store.subscribe(innerState); unsub();