From afe9a1b048df76a99626660f3c32e84980ee8dc6 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Sun, 12 Jun 2016 19:33:27 +0300 Subject: [PATCH] refactor: Drop enhancer API in favor of separate middlewares+reducer BREAKING CHANGE: makeMidiEnhancer has been removed. It became increasingly difficult to reason about its correctness with respect to other enhancers and particularly user-specified middleware. The new API is a little more verbose but allows the user to place MIDI I/O explicitly in the middleware chain as well as explicitly mount the required reducer into their state tree. Tests, documentation and the bare-input example are up-to-date. --- README.md | 2 +- examples/bare-input/configureStore.js | 35 ++--- examples/bare-input/index.js | 13 +- src/index.js | 207 +++++++++++++------------- src/observeStore.js | 6 +- test/specs/makeMidiEnhancer.js | 16 -- test/specs/midi-io.js | 42 +++--- test/utils/mockStore.js | 31 ---- 8 files changed, 150 insertions(+), 202 deletions(-) delete mode 100644 test/specs/makeMidiEnhancer.js delete mode 100644 test/utils/mockStore.js diff --git a/README.md b/README.md index c5f347b..4dc4f9a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [API documentation][doc-url] (up-to-date with `master`) -This module provides a store enhancer and a set of action creators wrapping the Web MIDI API for use in Redux apps. +This module provides middleware functions, a reducer and a set of action creators wrapping the Web MIDI API for use in Redux apps. * The list of MIDI devices is kept up-to-date in the state tree for your own reducers to use; updates are sent via the `RECEIVE_DEVICE_LIST` action. * Dispatch a `SEND_MIDI_MESSAGE` action with a device ID, MIDI data and optional timestamp, and it will be sent. diff --git a/examples/bare-input/configureStore.js b/examples/bare-input/configureStore.js index a9f8343..cb2c74b 100644 --- a/examples/bare-input/configureStore.js +++ b/examples/bare-input/configureStore.js @@ -1,25 +1,22 @@ -import { createStore, applyMiddleware, compose } from 'redux'; -import createLogger from 'redux-logger'; -import { makeMidiEnhancer } from '../../src'; +import setup, {reducer as midiReducer, RECEIVE_MIDI_MESSAGE} from '../../src'; +import {createStore, applyMiddleware, compose, combineReducers} from 'redux'; -const logger = createLogger(); +export default function configureStore () { + const rootReducer = combineReducers({ + midi: midiReducer, + midiMessages: (state, action) => { + if (!state) state = []; + if (action.type === RECEIVE_MIDI_MESSAGE) { + state = [action.payload, ...state]; + } + return state; + } + }); -export default function configureStore (initialState, reducer) { - let actions = []; + const {inputMiddleware, outputMiddleware} = setup({midiOptions: {sysex: true}}); - function collector ({getState}) { - return (next) => (action) => { - actions.push(action); - return next(action); - }; - } - const middleware = [collector, logger]; - - const store = createStore(reducer || (state => state), initialState, compose( - makeMidiEnhancer({midiOptions: {sysex: true}}), - applyMiddleware(...middleware), + return createStore(rootReducer, compose( + applyMiddleware(inputMiddleware, outputMiddleware), global.devToolsExtension ? global.devToolsExtension() : f => f )); - - return store; } diff --git a/examples/bare-input/index.js b/examples/bare-input/index.js index 6fac8c2..73d3d0b 100644 --- a/examples/bare-input/index.js +++ b/examples/bare-input/index.js @@ -1,15 +1,6 @@ import configureStore from './configureStore'; -import {setListeningDevices, RECEIVE_MIDI_MESSAGE} from '../../src'; - -const store = configureStore({}, (state, action) => { - if (!state.midiMessages) { - state = {...state, midiMessages: []}; - } - if (action.type === RECEIVE_MIDI_MESSAGE) { - state = {...state, midiMessages: [action.payload, ...state.midiMessages]}; - } - return state; -}); +import { setListeningDevices } from '../../src'; +const store = configureStore(); const deviceList = document.createElement('pre'); const messageLog = document.createElement('pre'); diff --git a/src/index.js b/src/index.js index 9acdc78..6292a07 100644 --- a/src/index.js +++ b/src/index.js @@ -42,8 +42,16 @@ const initialState = { devices: [], listeningDevices: [] }; - -const reducer = myDuck.createReducer({ +/** + * Reduces MIDI I/O and device discovery actions to state changes. + * Maintains state in two keys: devices (an array of device objects straight from the underlying Web MIDI API) and `listeningDevices` (an array of device IDs being listened to). + * @example + * import { createStore, applyMiddleware, combineReducers } from 'redux'; + * import setup, { reducer } from 'redux-midi'; + * const {inputMiddleware, outputMiddleware} = setup(); + * const store = createStore(combineReducers({midi: reducer}), initialState, applyMiddleware(inputMiddleware, outputMiddleware)); + */ +export const reducer = myDuck.createReducer({ [RECEIVE_DEVICE_LIST]: (state, action) => ({ ...state, devices: action.payload @@ -54,11 +62,6 @@ const reducer = myDuck.createReducer({ }) }, initialState); -export { reducer }; -export default reducer; - -import observeStore from './observeStore'; - import sortBy from 'lodash.sortby'; import deepEqual from 'deep-equal'; @@ -67,113 +70,109 @@ const defaultRequestMIDIAccess = (global && global.navigator && global.navigator ); /** - * Create a Redux {@link https://github.com/reactjs/redux/blob/master/docs/Glossary.md#store-enhancer|store enhancer} wrapping MIDI I/O and device discovery. + * Create a pair of Redux {@link https://github.com/reactjs/redux/blob/master/docs/Glossary.md#middleware|middleware} functions wrapping MIDI I/O and device discovery. + * The input middleware dispatches RECEIVE_DEVICE_LIST whenever the list of MIDI devices changes and RECEIVE_MIDI_MESSAGE when MIDI messages are received on devices that are being listened to. + * The output middleware sends MIDI messages to output devices as a side effect of SEND_MIDI_MESSAGE actions. * @param {MIDIOptions} [$0.midiOptions] - Options with which to invoke `requestMIDIAccess`. - * @param {string} [$0.stateKey='midi'] - The key under which the enhancer will store MIDI device information in the state. + * @param {string} [$0.stateKey='midi'] - The state key at which redux-midi's reducer is mounted. * @param {function(MIDIOptions): Promise} [$0.requestMIDIAccess=navigator.requestMIDIAccess] - Web MIDI API entry point. * @example - * // Basic usage - * import { createStore } from 'redux'; - * import { makeMidiEnhancer } from 'redux-midi'; - * const store = createStore(reducer, initialState, makeMidiEnhancer()); - * @example - * // With middleware - * import { createStore, applyMiddleware, compose } from 'redux'; - * import { makeMidiEnhancer } from 'redux-midi'; - * // assuming middleware is an array of Redux middleware functions - * const store = createStore(reducer, initialState, compose( - * makeMidiEnhancer({midiOptions: {sysex: true}}), - * applyMiddleware(...middleware) - * )); + * import { createStore, applyMiddleware, combineReducers } from 'redux'; + * import setup, { reducer } from 'redux-midi'; + * const {inputMiddleware, outputMiddleware} = setup(); + * const store = createStore(combineReducers({midi: reducer}), initialState, applyMiddleware(inputMiddleware, outputMiddleware)); */ -export const makeMidiEnhancer = ({midiOptions, stateKey = 'midi', requestMIDIAccess = defaultRequestMIDIAccess} = {}) => next => (userReducer, preloadedState) => { - let midiAccess = null; - - const enhancedReducer = (state = {}, action) => { - const midiState = state[stateKey]; - const nextMidiState = reducer(midiState, action); - - const nextState = userReducer(state, action) || {}; - if (nextMidiState !== nextState[stateKey]) { - return {...nextState, [stateKey]: nextMidiState}; - } - return nextState; +export default function setup ({midiOptions, stateKey = 'midi', requestMIDIAccess = defaultRequestMIDIAccess}) { + let midiAccess; + const requestMIDIAccessOnce = () => { + if (midiAccess) return Promise.resolve(midiAccess); + + midiAccess = requestMIDIAccess(midiOptions) + .then(receivedMidiAccess => { + midiAccess = receivedMidiAccess; + return midiAccess; + }); + return midiAccess; }; - const store = next(enhancedReducer, preloadedState); - - const enhancedStoreMethods = { - dispatch (action) { - action = store.dispatch(action); - if (action.type === SEND_MIDI_MESSAGE) { - const {payload} = action; - const {timestamp, data, device} = payload; - if (midiAccess) { - const {outputs} = midiAccess; - if (outputs.has(device)) { - outputs.get(device).send(data, timestamp); + const inputMiddleware = ({dispatch, getState}) => { + return next => { + requestMIDIAccessOnce().then(() => { + const sendDeviceList = () => { + const devices = sortBy([...midiAccess.inputs.values(), ...midiAccess.outputs.values()].map(device => ({ + id: device.id, + manufacturer: device.manufacturer, + name: device.name, + type: device.type, + version: device.version, + state: device.state, + connection: device.connection + })), 'id'); + if (!deepEqual(devices, getState()[stateKey].devices)) { + dispatch(receiveDeviceList(devices)); } + }; + + midiAccess.onstatechange = () => sendDeviceList(); + Promise.resolve() + .then(sendDeviceList); + }); + + return action => { + const toListen = []; + const toUnlisten = []; + const prevState = getState()[stateKey] || initialState; + action = next(action); + const state = getState()[stateKey]; + + if (state.listeningDevices !== prevState.listeningDevices) { + let prev = new Set(prevState ? prevState.listeningDevices : []); + let next = new Set(state.listeningDevices); + toUnlisten.push(...prevState.listeningDevices.filter(dev => !next.has(dev))); + toListen.push(...state.listeningDevices.filter(dev => !prev.has(dev))); } - } - return action; - } - }; - - requestMIDIAccess(midiOptions).then(receivedMidiAccess => { - midiAccess = receivedMidiAccess; - - const sendDeviceList = () => { - const devices = sortBy([...midiAccess.inputs.values(), ...midiAccess.outputs.values()].map(device => ({ - id: device.id, - manufacturer: device.manufacturer, - name: device.name, - type: device.type, - version: device.version, - state: device.state, - connection: device.connection - })), 'id'); - if (!deepEqual(devices, store.getState()[stateKey].devices)) { - store.dispatch(receiveDeviceList(devices)); - } - }; - midiAccess.onstatechange = () => sendDeviceList(); - - observeStore(store, state => state[stateKey], (state, prevState) => { - let toUnlisten = []; - let toListen = []; - - if (!prevState) prevState = initialState; - - if (state.listeningDevices !== prevState.listeningDevices) { - let prev = new Set(prevState ? prevState.listeningDevices : []); - let next = new Set(state.listeningDevices); - toUnlisten.push(...prevState.listeningDevices.filter(dev => !next.has(dev))); - toListen.push(...state.listeningDevices.filter(dev => !prev.has(dev))); - } + if (state.devices !== prevState.devices) { + let prev = new Set(prevState.devices.map(device => device.id)); + let next = new Set(state.devices.map(device => device.id)); + toListen.push(...state.listeningDevices.filter(device => midiAccess.inputs.has(device) && next.has(device) && !prev.has(device))); + } - if (state.devices !== prevState.devices) { - let prev = new Set(prevState.devices.map(device => device.id)); - let next = new Set(state.devices.map(device => device.id)); - toListen.push(...state.listeningDevices.filter(device => midiAccess.inputs.has(device) && next.has(device) && !prev.has(device))); - } + for (let device of toUnlisten) { + if (midiAccess.inputs.has(device)) { + midiAccess.inputs.get(device).onmidimessage = null; + } + } - for (let device of toUnlisten) { - if (midiAccess.inputs.has(device)) { - midiAccess.inputs.get(device).onmidimessage = null; + for (let device of toListen) { + if (midiAccess.inputs.has(device)) { + midiAccess.inputs.get(device).onmidimessage = ({receivedTime, timeStamp, timestamp, data}) => { + timestamp = [receivedTime, timeStamp, timestamp].filter(x => x !== undefined)[0]; + dispatch(receiveMidiMessage({ timestamp, data, device })); + }; + } } - } - - for (let device of toListen) { - if (midiAccess.inputs.has(device)) { - midiAccess.inputs.get(device).onmidimessage = ({receivedTime, timeStamp, timestamp, data}) => { - timestamp = [receivedTime, timeStamp, timestamp].filter(x => x !== undefined)[0]; - store.dispatch(receiveMidiMessage({ timestamp, data, device })); - }; + return action; + }; + }; + }; + + const outputMiddleware = () => next => action => { + action = next(action); + if (action.type === SEND_MIDI_MESSAGE) { + const {payload} = action; + const {timestamp, data, device} = payload; + const send = () => { + const {outputs} = midiAccess; + if (outputs.has(device)) { + outputs.get(device).send(data, timestamp); } - } - }); - sendDeviceList(); - }); - return {...store, ...enhancedStoreMethods}; -}; + }; + if (midiAccess && !midiAccess.then) send(); + else requestMIDIAccessOnce().then(send); + } + return action; + }; + + return { inputMiddleware, outputMiddleware }; +} diff --git a/src/observeStore.js b/src/observeStore.js index 34c1a47..ab52351 100644 --- a/src/observeStore.js +++ b/src/observeStore.js @@ -1,8 +1,8 @@ -export default function observeStore (store, select, onChange) { +export default function observeStore ({getState, subscribe}, select, onChange) { let currentState; function handleChange () { - const nextState = select(store.getState()); + const nextState = select(getState()); if (nextState !== currentState) { const prevState = currentState; currentState = nextState; @@ -10,7 +10,7 @@ export default function observeStore (store, select, onChange) { } } - let unsubscribe = store.subscribe(handleChange); + let unsubscribe = subscribe(handleChange); handleChange(); return unsubscribe; } diff --git a/test/specs/makeMidiEnhancer.js b/test/specs/makeMidiEnhancer.js deleted file mode 100644 index a74c9d1..0000000 --- a/test/specs/makeMidiEnhancer.js +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-env mocha */ - -import { makeMidiEnhancer } from '../../src'; -import { createStore } from 'redux'; - -describe('makeMidiEnhancer', () => { - it('should be a function', () => { - makeMidiEnhancer.should.be.a('function'); - }); - it('should be callable with no arguments', () => { - (() => makeMidiEnhancer()).should.not.throw; - }); - it('should work as a store enhancer', () => { - createStore(x => (x || {}), makeMidiEnhancer()); - }); -}); diff --git a/test/specs/midi-io.js b/test/specs/midi-io.js index 4ecdb13..b20b383 100644 --- a/test/specs/midi-io.js +++ b/test/specs/midi-io.js @@ -1,25 +1,33 @@ /* eslint-env mocha */ -import { makeMidiEnhancer, SEND_MIDI_MESSAGE, RECEIVE_MIDI_MESSAGE, SET_LISTENING_DEVICES } from '../../src'; +import setup, { reducer, SEND_MIDI_MESSAGE, RECEIVE_MIDI_MESSAGE, SET_LISTENING_DEVICES } from '../../src'; import MidiApi from 'web-midi-test-api'; -import createMockStore from '../utils/mockStore'; import unique from 'lodash.uniq'; import sinon from 'sinon'; -import { applyMiddleware, compose } from 'redux'; +import { applyMiddleware, createStore, combineReducers } from 'redux'; +import createLogger from 'redux-logger'; + +const debug = false; + +const messageConverter = () => next => action => { + if (action.type === 'NOT_A_MIDI_MESSAGE') { + return next({...action, type: SEND_MIDI_MESSAGE}); + } + return next(action); +}; describe('MIDI I/O', () => { - let store, api; + let store, api, actions; + const collector = ({getState}) => (next) => (action) => { + actions.push(action); + return next(action); + }; beforeEach(() => { + actions = []; + const logger = createLogger({colors: false}); api = new MidiApi(); - const middleware = () => next => action => { - if (action.type === 'NOT_A_MIDI_MESSAGE') { - return next({...action, type: SEND_MIDI_MESSAGE}); - } - return next(action); - }; - store = createMockStore(undefined, compose(makeMidiEnhancer({ - requestMIDIAccess: api.requestMIDIAccess - }), applyMiddleware(middleware))); + const {inputMiddleware, outputMiddleware} = setup({requestMIDIAccess: api.requestMIDIAccess}); + store = createStore(combineReducers({midi: reducer}), applyMiddleware(inputMiddleware, messageConverter, outputMiddleware, collector, ...(debug ? [logger] : []))); return new Promise(resolve => setImmediate(resolve)); }); it('should see no devices to begin with', () => { @@ -82,15 +90,15 @@ describe('MIDI I/O', () => { const inputs = devices.filter(device => device.type === 'input'); const inputIds = inputs.map(device => device.id); store.dispatch({ type: SET_LISTENING_DEVICES, payload: inputs.map(device => device.id) }); - store.clearActions(); + actions = []; device.outputs[0].send([0x80, 0x7f, 0x7f], 1234); - store.getActions().should.deep.equal([{ type: RECEIVE_MIDI_MESSAGE, payload: {data: new Uint8Array([0x80, 0x7f, 0x7f]), timestamp: 1234, device: inputIds[0]} }]); + actions.should.deep.equal([{ type: RECEIVE_MIDI_MESSAGE, payload: {data: new Uint8Array([0x80, 0x7f, 0x7f]), timestamp: 1234, device: inputIds[0]} }]); }); it('should not receive message when not listening', () => { store.dispatch({ type: SET_LISTENING_DEVICES, payload: [] }); - store.clearActions(); + actions = []; device.outputs[0].send([0x80, 0x7f, 0x7f]); - store.getActions().should.be.empty; + actions.should.be.empty; }); it('should send message to a valid device', () => { const devices = store.getState().midi.devices; diff --git a/test/utils/mockStore.js b/test/utils/mockStore.js deleted file mode 100644 index 55ce622..0000000 --- a/test/utils/mockStore.js +++ /dev/null @@ -1,31 +0,0 @@ -import { createStore, applyMiddleware, compose } from 'redux'; - -import createLogger from 'redux-logger'; - -const logger = createLogger({colors: false}); - -const debug = false; - -export default function createMockStore (initialState, preEnhancer = f => f) { - let actions = []; - - function collector ({getState}) { - return (next) => (action) => { - actions.push(action); - return next(action); - }; - } - const middleware = [collector].concat(debug ? [logger] : []); - - const store = createStore(state => state, initialState, compose( - preEnhancer, - applyMiddleware(...middleware), - global.devToolsExtension ? global.devToolsExtension() : f => f - )); - - store.getActions = () => actions; - store.clearActions = () => { - actions = []; - }; - return store; -}