Skip to content

Commit e5bcbbe

Browse files
committed
feat(store): add observable proposal interop to store
- Adds dependency on `symbol-observable` to pull in `Symbol.observable` - Adds `Symbol.observable` method to the store that returns a generic observable - Adds comprehensive tests to ensure interoperability. (rxjs 5 was used for a simple integration test, and is a dev only dependency) closes #1631
1 parent f02e825 commit e5bcbbe

File tree

3 files changed

+165
-2
lines changed

3 files changed

+165
-2
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363
"dependencies": {
6464
"lodash": "^4.2.1",
6565
"lodash-es": "^4.2.1",
66-
"loose-envify": "^1.1.0"
66+
"loose-envify": "^1.1.0",
67+
"symbol-observable": "^0.2.1"
6768
},
6869
"devDependencies": {
6970
"babel-cli": "^6.3.15",
@@ -101,6 +102,7 @@
101102
"isparta": "^4.0.0",
102103
"mocha": "^2.2.5",
103104
"rimraf": "^2.3.4",
105+
"rxjs": "^5.0.0-beta.6",
104106
"typescript": "^1.8.0",
105107
"typescript-definition-tester": "0.0.4",
106108
"webpack": "^1.9.6"

src/createStore.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import isPlainObject from 'lodash/isPlainObject'
2+
import $$observable from 'symbol-observable'
23

34
/**
45
* These are private action types reserved by Redux.
@@ -198,6 +199,49 @@ export default function createStore(reducer, initialState, enhancer) {
198199
dispatch({ type: ActionTypes.INIT })
199200
}
200201

202+
/**
203+
* Interoperability point for observable/reactive libraries.
204+
* @returns {observable} A minimal observable of state changes.
205+
* For more information, see the observable proposal:
206+
* https://github.com/zenparsing/es-observable
207+
*/
208+
function observable() {
209+
var outerSubscribe = subscribe
210+
return {
211+
/**
212+
* The minimal observable subscription method.
213+
* @param {Object} observer Any object that can be used as an observer.
214+
* The observer object should have a `next` method.
215+
* @returns {subscription} An object with an `unsubscribe` method that can
216+
* be used to unsubscribe the observable from the store, and prevent further
217+
* emission of values from the observable.
218+
*/
219+
subscribe(observer) {
220+
if (typeof observer !== 'object') {
221+
throw new TypeError('Expected observer to be an object')
222+
}
223+
224+
var observeState = () => {
225+
if (observer.next) {
226+
observer.next(getState())
227+
}
228+
}
229+
230+
// send initial state to observer
231+
observeState()
232+
233+
// send subsequent states to observer
234+
var unsubscribe = outerSubscribe(observeState)
235+
236+
// return an unsubscribable
237+
return { unsubscribe }
238+
},
239+
[$$observable]() {
240+
return this
241+
}
242+
}
243+
}
244+
201245
// When a store is created, an "INIT" action is dispatched so that every
202246
// reducer returns their initial state. This effectively populates
203247
// the initial state tree.
@@ -207,6 +251,7 @@ export default function createStore(reducer, initialState, enhancer) {
207251
dispatch,
208252
subscribe,
209253
getState,
210-
replaceReducer
254+
replaceReducer,
255+
[$$observable]: observable
211256
}
212257
}

test/createStore.spec.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import expect from 'expect'
22
import { createStore, combineReducers } from '../src/index'
33
import { addTodo, dispatchInMiddle, throwError, unknownAction } from './helpers/actionCreators'
44
import * as reducers from './helpers/reducers'
5+
import * as Rx from 'rxjs'
6+
import $$observable from 'symbol-observable'
57

68
describe('createStore', () => {
79
it('exposes the public API', () => {
@@ -610,4 +612,118 @@ describe('createStore', () => {
610612
store.subscribe(undefined)
611613
).toThrow()
612614
})
615+
616+
describe('Symbol.observable interop point', () => {
617+
it('should exist', () => {
618+
const store = createStore(() => {})
619+
expect(typeof store[$$observable]).toBe('function')
620+
})
621+
622+
describe('returned value', () => {
623+
it('should be subscribable', () => {
624+
const store = createStore(() => {})
625+
const obs = store[$$observable]()
626+
expect(typeof obs.subscribe).toBe('function')
627+
})
628+
629+
it('should throw a TypeError if an observer object is not supplied to subscribe', () => {
630+
const store = createStore(() => {})
631+
const obs = store[$$observable]()
632+
633+
expect(function () {
634+
obs.subscribe()
635+
}).toThrow()
636+
637+
expect(function () {
638+
obs.subscribe(() => {})
639+
}).toThrow()
640+
641+
expect(function () {
642+
obs.subscribe({})
643+
}).toNotThrow()
644+
})
645+
646+
it('should return a subscription object when subscribed', () => {
647+
const store = createStore(() => {})
648+
const obs = store[$$observable]()
649+
const sub = obs.subscribe({})
650+
expect(typeof sub.unsubscribe).toBe('function')
651+
})
652+
})
653+
654+
it('should pass an integration test with no unsubscribe', () => {
655+
function foo(state = 0, action) {
656+
return action.type === 'foo' ? 1 : state
657+
}
658+
659+
function bar(state = 0, action) {
660+
return action.type === 'bar' ? 2 : state
661+
}
662+
663+
const store = createStore(combineReducers({ foo, bar }))
664+
const observable = store[$$observable]()
665+
const results = []
666+
667+
observable.subscribe({
668+
next(state) {
669+
results.push(state)
670+
}
671+
})
672+
673+
store.dispatch({ type: 'foo' })
674+
store.dispatch({ type: 'bar' })
675+
676+
expect(results).toEqual([ { foo: 0, bar: 0 }, { foo: 1, bar: 0 }, { foo: 1, bar: 2 } ])
677+
})
678+
679+
it('should pass an integration test with an unsubscribe', () => {
680+
function foo(state = 0, action) {
681+
return action.type === 'foo' ? 1 : state
682+
}
683+
684+
function bar(state = 0, action) {
685+
return action.type === 'bar' ? 2 : state
686+
}
687+
688+
const store = createStore(combineReducers({ foo, bar }))
689+
const observable = store[$$observable]()
690+
const results = []
691+
692+
const sub = observable.subscribe({
693+
next(state) {
694+
results.push(state)
695+
}
696+
})
697+
698+
store.dispatch({ type: 'foo' })
699+
sub.unsubscribe()
700+
store.dispatch({ type: 'bar' })
701+
702+
expect(results).toEqual([ { foo: 0, bar: 0 }, { foo: 1, bar: 0 } ])
703+
})
704+
705+
it('should pass an integration test with a common library (RxJS)', () => {
706+
function foo(state = 0, action) {
707+
return action.type === 'foo' ? 1 : state
708+
}
709+
710+
function bar(state = 0, action) {
711+
return action.type === 'bar' ? 2 : state
712+
}
713+
714+
const store = createStore(combineReducers({ foo, bar }))
715+
const observable = Rx.Observable.from(store)
716+
const results = []
717+
718+
const sub = observable
719+
.map(state => ({ fromRx: true, ...state }))
720+
.subscribe(state => results.push(state))
721+
722+
store.dispatch({ type: 'foo' })
723+
sub.unsubscribe()
724+
store.dispatch({ type: 'bar' })
725+
726+
expect(results).toEqual([ { foo: 0, bar: 0, fromRx: true }, { foo: 1, bar: 0, fromRx: true } ])
727+
})
728+
})
613729
})

0 commit comments

Comments
 (0)