Skip to content

Commit

Permalink
refactor: let use-subscription support closure accessing
Browse files Browse the repository at this point in the history
deprecated use-observable-props-callback in favor or the new
use-subscription

BREAKING CHANGE: useSubscription supports only one way to pass subscribe arguments for simplicity. |useSubscription used to ignore changes of the subscribe functions, now it will always call the latest one.
  • Loading branch information
crimx committed Aug 21, 2019
1 parent 2a2b92c commit dd06f9e
Show file tree
Hide file tree
Showing 4 changed files with 169 additions and 157 deletions.
41 changes: 2 additions & 39 deletions src/use-observable-props-callback.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,8 @@
import { Observable, Subscription } from 'rxjs'
import { withLatestFrom } from 'rxjs/operators'
import { useSubscription } from './use-subscription'
import { useObservable } from './use-observable'

/**
* Whenever the Observable emits a value, callback
* is called with that value.
*
* Note that changes of callback will not trigger
* an emission. If you need that just create another
* Observable with `useObservable`.
*
* Examples:
*
* ```typescript
* const events$ = useObservable(() => interval(1000))
*
* useObservablePropsCallback(events$, props.onChange)
* ```
*
* So why not use [[useSubscription]]?
*
* ```typescript
* useSubscription(events$, props.onChange)
* ```
*
* [[useSubscription]] works the same if `props.onChange` never changes.
* `useObservablePropsCallback` ensures the latest `props.onChange` is called.
* @deprecated use [[useSubscription]] instead.
*/
export function useObservablePropsCallback<Event>(
events$: Observable<Event>,
callback: (e: Event) => any
): Subscription {
const enhanced$ = useObservable(
callbacks$ => events$.pipe(withLatestFrom(callbacks$)),
[callback] as [typeof callback]
)
return useSubscription(enhanced$, subscribe)
}

/** @ignore */
function subscribe<E, T extends Function>([e, [callback]]: [E, [T]]) {
callback(e)
}
export const useObservablePropsCallback = useSubscription
93 changes: 72 additions & 21 deletions src/use-subscription.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,103 @@
import { PartialObserver, Observable, Subscription } from 'rxjs'
import { useRefFn } from './helpers'
import { useEffect } from 'react'
import { Observable, Subscription } from 'rxjs'
import { useRefFn, emptyTuple } from './helpers'
import { useEffect, useRef } from 'react'

/**
* Accepts an Observable and RxJS subscribe parameters.
* Deprecated subscribe parameter types are not included
* but you can use it anyway if not writing TypeScript.
* Accepts an Observable and optional `next`, `error`, `complete` functions.
* These functions must be in correct order.
* Use `undefined` or `null` for placeholder.
*
* Subscription will unsubscribe when unmount, you can also
* unsubscribe manually.
*
* Note that `useSubscription` will only subscribe once.
* Subsequent changes of the callback functions will be ignored.
* If you need that, take a look at [[useObservablePropsCallback]]
* or create an stream of callbacks yourself.
* Note that changes of callbacks will not trigger
* an emission. If you need that just create another
* Observable of the callback with [[useObservable]].
*
* You can also access closure in the callback like in `useEffect`.
* `useSubscription` will ensure the latest callback is called.
*
* Examples:
*
* ```typescript
* const subscription = useSubscription(events$, e => console.log(e.type))
* ```
*
* Or:
* On complete
*
* ```typescript
* const subscription = useSubscription(events$, null, null, () => console.log('complete'))
* ```
*
* Access closure:
*
* ```typescript
* const subscription = useSubscription(events$, {
* next: console.log,
* error: console.error,
* complete: () => console.log('complete')
* const [debug, setDebug] = useState(false)
* const subscription = useSubscription(events$, null, error => {
* if (debug) {
* console.log(error)
* }
* })
* ```
*/
export function useSubscription<T>(stream$: Observable<T>): Subscription
export function useSubscription<T>(
stream$: Observable<T>,
observer?: PartialObserver<T>
next: (value: T) => void | null | undefined
): Subscription
export function useSubscription<T>(
stream$: Observable<T>,
next?: (value: T) => void,
error?: (error: any) => void,
complete?: () => void
next: (value: T) => void | null | undefined,
error: (error: any) => void | null | undefined
): Subscription
export function useSubscription<T>(
stream$: Observable<T>,
...args: any[]
next: (value: T) => void | null | undefined,
error: (error: any) => void | null | undefined,
complete: () => void | null | undefined
): Subscription
export function useSubscription<T>(
stream$: Observable<T>,
...args:
| []
| [(value: T) => void | null | undefined]
| [
(value: T) => void | null | undefined,
(error: any) => void | null | undefined
]
| [
(value: T) => void | null | undefined,
(error: any) => void | null | undefined,
() => void | null | undefined
]
): Subscription {
const subscriptionRef = useRefFn(() => stream$.subscribe(...args))
const argsRef = useRef<
Readonly<
| []
| [(value: T) => void | null | undefined]
| [
(value: T) => void | null | undefined,
(error: any) => void | null | undefined
]
| [
(value: T) => void | null | undefined,
(error: any) => void | null | undefined,
() => void | null | undefined
]
>
>(emptyTuple)
argsRef.current = args

const subscriptionRef = useRefFn(() =>
stream$.subscribe({
next: value => argsRef.current[0] && argsRef.current[0](value),
error: error => argsRef.current[1] && argsRef.current[1](error),
complete: () => argsRef.current[2] && argsRef.current[2]()
})
)

// unsubscribe when unmount
useEffect(() => () => subscriptionRef.current.unsubscribe(), [])

return subscriptionRef.current
}
95 changes: 0 additions & 95 deletions test/use-observable-props-callback.spec.ts

This file was deleted.

97 changes: 95 additions & 2 deletions test/use-subscription.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useSubscription } from '../src'
import { renderHook } from '@testing-library/react-hooks'
import { of, BehaviorSubject } from 'rxjs'
import { renderHook, act } from '@testing-library/react-hooks'
import { of, BehaviorSubject, Subject } from 'rxjs'
import { useState } from 'react'

describe('useSubscription', () => {
it('should always return the same Subscription', () => {
Expand All @@ -26,6 +27,98 @@ describe('useSubscription', () => {
expect(numSpy).lastCalledWith(3)
})

it('should invoke the latest callback', () => {
const num$$ = new Subject()
const spy1 = jest.fn()
const spy2 = jest.fn()
const { rerender } = renderHook(
props => {
useSubscription(num$$, props.cb)
},
{
initialProps: {
cb: spy1
}
}
)
expect(spy1).toBeCalledTimes(0)
expect(spy2).toBeCalledTimes(0)
num$$.next(1)
expect(spy1).toBeCalledTimes(1)
expect(spy1).lastCalledWith(1)
expect(spy2).toBeCalledTimes(0)
spy1.mockClear()
spy2.mockClear()
rerender({ cb: spy2 })
num$$.next(2)
expect(spy1).toBeCalledTimes(0)
expect(spy2).toBeCalledTimes(1)
expect(spy2).lastCalledWith(2)
})

it('should be able to access closure', () => {
const num$$ = new Subject<number>()
const numSpy = jest.fn()
const { rerender, result } = renderHook(
props => {
const [stateVal, setState] = useState('s1')
useSubscription(num$$, num => {
numSpy(num, stateVal, props.propVal)
})
return { setState }
},
{
initialProps: {
propVal: 'p1'
}
}
)
expect(numSpy).toBeCalledTimes(0)
num$$.next(1)
expect(numSpy).lastCalledWith(1, 's1', 'p1')

numSpy.mockClear()
act(() => {
result.current.setState('s2')
})
expect(numSpy).toBeCalledTimes(0)
num$$.next(2)
expect(numSpy).lastCalledWith(2, 's2', 'p1')

numSpy.mockClear()
rerender({ propVal: 'p2' })
expect(numSpy).toBeCalledTimes(0)
num$$.next(2)
expect(numSpy).lastCalledWith(2, 's2', 'p2')
})

it('should not emit value for callback changing', () => {
const num$$ = new Subject()
const spy1 = jest.fn()
const spy2 = jest.fn()
const { rerender } = renderHook(
props => {
useSubscription(num$$, props.cb)
},
{
initialProps: {
cb: spy1
}
}
)
expect(spy1).toBeCalledTimes(0)
expect(spy2).toBeCalledTimes(0)
num$$.next(1)
expect(spy1).toBeCalledTimes(1)
expect(spy1).lastCalledWith(1)
expect(spy2).toBeCalledTimes(0)
spy1.mockClear()
spy2.mockClear()
rerender({ cb: spy2 })
expect(spy1).toBeCalledTimes(0)
expect(spy2).toBeCalledTimes(0)
})

it('should unsubscribe when unmount', () => {
const num$$ = new BehaviorSubject(1)
const numSpy = jest.fn()
Expand Down

0 comments on commit dd06f9e

Please sign in to comment.