Skip to content

Commit f99c2ba

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 f99c2ba

File tree

3 files changed

+157
-2
lines changed

3 files changed

+157
-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: 38 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,41 @@ export default function createStore(reducer, initialState, enhancer) {
198199
dispatch({ type: ActionTypes.INIT })
199200
}
200201

202+
/**
203+
* Interop point for observable libraries
204+
* @returns {observable} minimal observable of state changes. This was added for Interop
205+
* For more information, see the
206+
* [observable proposal](https://github.com/zenparsing/es-observable)
207+
*/
208+
function observable() {
209+
var outerSubscribe = subscribe
210+
return {
211+
subscribe(observer) {
212+
if (typeof observer !== 'object') {
213+
throw new TypeError('Expected observer to be an object')
214+
}
215+
216+
var observeState = () => {
217+
if (observer.next) {
218+
observer.next(getState())
219+
}
220+
}
221+
222+
// send initial state to observer
223+
observeState()
224+
225+
// send subsequent states to observer
226+
var unsubscribe = outerSubscribe(observeState)
227+
228+
// return an unsubscribable
229+
return { unsubscribe }
230+
},
231+
[$$observable]() {
232+
return this
233+
}
234+
}
235+
}
236+
201237
// When a store is created, an "INIT" action is dispatched so that every
202238
// reducer returns their initial state. This effectively populates
203239
// the initial state tree.
@@ -207,6 +243,7 @@ export default function createStore(reducer, initialState, enhancer) {
207243
dispatch,
208244
subscribe,
209245
getState,
210-
replaceReducer
246+
replaceReducer,
247+
[$$observable]: observable
211248
}
212249
}

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)