From 82f5d53df2198e454924d1d206d0cfcbd31b1f68 Mon Sep 17 00:00:00 2001 From: Yanick Champoux Date: Tue, 6 Sep 2022 12:54:23 -0400 Subject: [PATCH] add splatReaction support --- src/Updux.js | 110 +++++++++++++++++++++++--------- src/splatReactions.test.js | 127 +++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 29 deletions(-) create mode 100644 src/splatReactions.test.js diff --git a/src/Updux.js b/src/Updux.js index b178577..d36f395 100644 --- a/src/Updux.js +++ b/src/Updux.js @@ -14,6 +14,7 @@ import { action, isActionGen } from './actions.js'; */ export class Updux { + #name = 'unknown'; #localInitial = {}; #subduxes = {}; #actions; @@ -23,10 +24,15 @@ export class Updux { #effects = []; #localReactions = []; #middlewareWrapper; + #splatReactionMapper; constructor(config = {}) { this.#config = config; + this.#name = config.name || 'unknown'; + + this.#splatReactionMapper = config.splatReactionMapper; + this.#middlewareWrapper = config.middlewareWrapper; this.#localInitial = config.initial; @@ -58,6 +64,10 @@ export class Updux { this.#localReactions = config.reactions ?? []; } + get name() { + return this.#name; + } + #addSubduxActions(_slice, subdux) { if (!subdux.actions) return; // TODO action 'blah' defined multiple times: @@ -206,16 +216,15 @@ export class Updux { } subscribeTo(store, subscription) { - const localStore = this.augmentMiddlewareApi( - { - ...store, - subscribe: (subscriber) => this.subscribeTo(store, subscriber), // TODO not sure - } - ); + const localStore = this.augmentMiddlewareApi({ + ...store, + subscribe: (subscriber) => this.subscribeTo(store, subscriber), // TODO not sure + }); const subscriber = subscription(localStore); let previous; + let unsub; const memoSub = () => { const state = store.getState(); @@ -225,38 +234,81 @@ export class Updux { subscriber(state, p, unsub); }; - let ret = store.subscribe(memoSub); - const unsub = typeof ret === 'function' ? ret : ret.unsub; - return { - unsub, - subscriberMemoized: memoSub, - subscriber, + 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]; + } + }; }; } + subscribeSplatReaction(store) { + return this.subscribeTo(store, this.createSplatReaction()); + } + subscribeAll(store) { - let results = this.#localReactions.map((sub) => + let unsubs = this.#localReactions.map((sub) => this.subscribeTo(store, sub), ); - for (const subdux in this.#subduxes) { - if (subdux !== '*') { - const localStore = { - ...store, - getState: () => store.getState()[subdux], - }; - results.push(this.#subduxes[subdux].subscribeAll(localStore)); - } + if (this.#splatReactionMapper) { + unsubs.push(this.subscribeSplatReaction(store)); } - return { - unsub: () => results.forEach(({ unsub }) => unsub()), - subscriberMemoized: () => - results.forEach(({ subscriberMemoized }) => - subscriberMemoized(), - ), - subscriber: () => results.forEach(({ subscriber }) => subscriber()), - }; + unsubs.push( + ...Object.entries(this.#subduxes) + .filter(([slice]) => slice !== '*') + .map(([slice, subdux]) => { + subdux.subscribeAll({ + ...store, + getState: () => store.getState()[slice], + }); + }), + ); + + return () => unsubs.forEach((u) => u()); } } diff --git a/src/splatReactions.test.js b/src/splatReactions.test.js new file mode 100644 index 0000000..7fbe918 --- /dev/null +++ b/src/splatReactions.test.js @@ -0,0 +1,127 @@ +import { test, expect, vi, describe } from 'vitest'; +import u from 'updeep'; + +import { Updux } from './Updux.js'; +import { matches } from './utils'; + +const reactionSnitch = vi.fn(); +const thingReactionSnitch = vi.fn(); + +const subThing = new Updux({ + name: 'subThing', + initial: 0, +}); + +subThing.addReaction((api) => (state, previousState, unsubscribe) => { + reactionSnitch({ ...api, state, previousState }); +}); + +const thing = new Updux({ + name: 'thing', + initial: {}, + subduxes: { + '*': subThing, + }, + actions: { + setSubThing: (id, value, thingId) => ({ thingId, id, value }), + deleteSubThing: (id) => id, + }, + mutations: { + setSubThing: ({ id, value }) => u.updateIn(id, value), + deleteSubThing: (id) => u.updateIn(id, u.omitted), + }, + splatReactionMapper: ({ id }) => id, +}); + +thing.addReaction((api) => (state, previousState, unsubscribe) => { + thingReactionSnitch({ ...api, state, previousState }); +}); + +const things = new Updux({ + subduxes: { + '*': thing, + }, + initial: {}, + actions: { newThing: (id) => id }, + splatReactionMapper: ({ id }) => id, + mutations: { + newThing: (id) => (state) => ({ ...state, [id]: thing.initial }), + }, +}); + +things.setMutation( + 'setSubThing', + ({ thingId }, action) => u.updateIn(thingId, thing.upreducer(action)), + true, +); + +describe('just one level', () => { + const store = thing.createStore(); + + test('set', async () => { + store.dispatch.setSubThing('a', 13); + + expect(reactionSnitch).toHaveBeenCalledWith( + expect.objectContaining({ state: 13 }), + ); + }); + + test('other key', async () => { + reactionSnitch.mockReset(); + + store.dispatch.setSubThing('b', 23); + + expect(reactionSnitch).not.toHaveBeenCalledWith( + expect.objectContaining({ state: 13 }), + ); + expect(reactionSnitch).toHaveBeenCalledWith( + expect.objectContaining({ state: 23 }), + ); + }); + + test('delete', async () => { + reactionSnitch.mockReset(); + + store.dispatch.deleteSubThing('a'); + + expect(reactionSnitch).toHaveBeenCalledOnce(); + + expect(reactionSnitch).toHaveBeenCalledWith( + expect.objectContaining({ state: null }), + ); + }); + + test('context', async () => { + expect(reactionSnitch).toHaveBeenCalledWith( + expect.objectContaining({ + context: { subThing: 'a' }, + }), + ); + }); +}); + +test('two levels', async () => { + const store = things.createStore(); + + reactionSnitch.mockReset(); + thingReactionSnitch.mockReset(); + + store.dispatch.newThing('alpha'); + store.dispatch.newThing('beta'); + store.dispatch.setSubThing('a', 13, 'alpha'); + + expect(reactionSnitch).toHaveBeenCalledWith( + expect.objectContaining({ + context: { thing: 'alpha', subThing: 'a' }, + state: 13, + }), + ); + expect(thingReactionSnitch).toHaveBeenCalledWith( + expect.objectContaining({ + context: { + thing: 'alpha', + }, + state: { a: 13 }, + }), + ); +});