react-rx
Version:
React + RxJS = <3
289 lines (229 loc) • 9.3 kB
text/typescript
import {act, render, renderHook} from '@testing-library/react'
import {createElement, Fragment, useMemo} from 'react'
import {asyncScheduler, Observable, of, ReplaySubject, scheduled, share, Subject, timer} from 'rxjs'
import {map} from 'rxjs/operators'
import {expect, test} from 'vitest'
import {useObservable} from '../useObservable'
test('should subscribe immediately on component mount and unsubscribe on component unmount', async () => {
let subscribed = false
const observable = new Observable(() => {
subscribed = true
return () => {
subscribed = false
}
})
expect(subscribed).toBe(false)
const {unmount} = renderHook(() => useObservable(observable))
expect(subscribed).toBe(true)
unmount()
await Promise.resolve()
expect(subscribed).toBe(false)
})
test('should only subscribe once when given same observable on re-renders', async () => {
let subscriptionCount = 0
const observable = new Observable(() => {
subscriptionCount++
})
expect(subscriptionCount).toBe(0)
const {unmount, rerender} = renderHook(() => useObservable(observable))
expect(subscriptionCount).toBe(1)
rerender()
expect(subscriptionCount).toBe(1)
unmount()
await Promise.resolve()
renderHook(() => useObservable(observable))
expect(subscriptionCount).toBe(2)
})
test('should not return undefined during render if initial value is given', () => {
const observable = timer(100).pipe(map(() => 'emitted value'))
const returnedValues: unknown[] = []
function ObservableComponent() {
const observedValue = useObservable(observable, 'initial value')
returnedValues.push(observedValue)
return createElement(Fragment, null, observedValue)
}
render(createElement(ObservableComponent))
expect(returnedValues).toEqual(expect.arrayContaining(['initial value']))
})
test('should not return undefined during render if observable is sync', () => {
const observable = of('initial value')
const returnedValues: unknown[] = []
function ObservableComponent() {
const observedValue = useObservable(observable)
returnedValues.push(observedValue)
return createElement(Fragment, null, observedValue)
}
render(createElement(ObservableComponent))
expect(returnedValues).toEqual(expect.arrayContaining(['initial value']))
})
test('should return undefined during first render if observable is async', () => {
const observable = scheduled('async value', asyncScheduler)
const returnedValues: unknown[] = []
function ObservableComponent() {
const observedValue = useObservable(observable)
returnedValues.push(observedValue)
return createElement(Fragment, null, observedValue)
}
render(createElement(ObservableComponent))
expect(returnedValues).toEqual(expect.arrayContaining([undefined]))
})
test('should have sync values from an observable as initial value', () => {
const observable = of('something sync')
const {result} = renderHook(() => useObservable(observable))
expect(result.current).toBe('something sync')
})
test('should have undefined as initial value from delayed observables', () => {
const {result, unmount} = renderHook(() =>
useObservable(scheduled('something async', asyncScheduler)),
)
expect(result.current).toBeUndefined()
unmount()
})
test('should have passed initialValue as initial value from delayed observables', () => {
const {result, unmount} = renderHook(() =>
useObservable(scheduled('something async', asyncScheduler), 'initial'),
)
expect(result.current).toBe('initial')
unmount()
})
test('should rerender with initial value if component unmounts and then remounts', async () => {
const values$ = new Subject<string>()
const firstHook = renderHook(() => useObservable(values$, 'initial'))
expect(firstHook.result.current).toBe('initial')
act(() => values$.next('something'))
expect(firstHook.result.current).toBe('something')
firstHook.unmount()
await Promise.resolve()
const nextHook = renderHook(() => useObservable(values$, 'initial2'))
expect(nextHook.result.current).toBe('initial2')
})
test('should share the observable between each concurrent subscribing hook', async () => {
let subscribeCount = 0
const observable = new Observable<number>((subscriber) => {
subscriber.next(subscribeCount++)
})
const firstHook = renderHook(() => useObservable(observable))
expect(firstHook.result.current).toBe(0)
const secondHook = renderHook(() => useObservable(observable))
expect(secondHook.result.current).toBe(0)
firstHook.unmount()
secondHook.unmount()
await Promise.resolve()
const thirdHook = renderHook(() => useObservable(observable))
expect(thirdHook.result.current).toBe(1)
thirdHook.unmount()
})
test('should restart any completed observable on mount', async () => {
let subscribeCount = 0
let unsubscribeCount = 0
type Notification<T> =
| {kind: 'next'; value: T}
| {kind: 'error'; error: Error}
| {kind: 'complete'}
const notifications$ = new Subject<Notification<string>>()
const observable = new Observable<string>((subscriber) => {
subscribeCount++
const subscription = notifications$.subscribe((notification) => {
if (notification.kind === 'next') {
subscriber.next(notification.value)
} else if (notification.kind === 'error') {
subscriber.error(notification.error)
} else if (notification.kind === 'complete') {
subscriber.complete()
}
})
return () => {
unsubscribeCount++
subscription.unsubscribe()
}
}).pipe(share({connector: () => new ReplaySubject(1)}))
const firstHook = renderHook(() => useObservable(observable, 'initial'))
expect(firstHook.result.current).toBe('initial')
act(() => notifications$.next({kind: 'next', value: 'something'}))
expect(firstHook.result.current).toBe('something')
act(() => notifications$.next({kind: 'complete'}))
expect(firstHook.result.current).toBe('something')
act(() => notifications$.next({kind: 'next', value: 'after complete'}))
expect(firstHook.result.current).toBe('something')
expect(subscribeCount).toBe(1)
expect(unsubscribeCount).toBe(1)
firstHook.unmount()
await Promise.resolve()
const secondHook = renderHook(() => useObservable(observable))
expect(secondHook.result.current).toBe(undefined)
expect(subscribeCount).toBe(2)
expect(unsubscribeCount).toBe(1)
secondHook.unmount()
await Promise.resolve()
expect(unsubscribeCount).toBe(2)
})
test('should update with values from observables', () => {
const values$ = new Subject<string>()
const {result, unmount} = renderHook(() => useObservable(values$))
expect(result.current).toBe(undefined)
act(() => values$.next('something'))
expect(result.current).toBe('something')
act(() => values$.next('otherthing'))
expect(result.current).toBe('otherthing')
unmount()
})
test('should re-subscribe when receiving a new observable', () => {
const first$ = new Subject<string>()
const second$ = new Subject<string>()
let current$ = first$
const {result, rerender, unmount} = renderHook(() => useObservable(current$, '!!initial!!'))
act(() => first$.next('first 1'))
expect(result.current).toBe('first 1')
current$ = second$
rerender()
// since observable #2 hasn't emitted a value yet, we should use the initial value
expect(result.current).toBe('!!initial!!')
// Now we should be subscribed to second$ and it's emission should be returned
act(() => second$.next('second 1'))
expect(result.current).toBe('second 1')
// we should no longer be subscribed to the first and ignore any emissions
act(() => first$.next('first 2'))
expect(result.current).toBe('second 1')
unmount()
})
test('should return undefined if observable emits undefined, also when given initial value', () => {
const values$ = new Subject<string | undefined>()
const {result, unmount} = renderHook(() => useObservable(values$, 'initial'))
expect(result.current).toBe('initial')
act(() => values$.next(undefined))
expect(result.current).toBe(undefined)
unmount()
})
test('should return undefined if observable emits undefined, also when given initial value, and also when unsubscribe + resubscribe', () => {
const snapshots: (string | undefined)[] = []
const subject = new Subject<string | undefined>()
function ObservableComponent(props: {prefix: string}) {
// will create a new observable every time prefix changes
const observable = useMemo(
() => subject.pipe(map((v) => (typeof v === 'string' ? `${props.prefix}-${v}` : v))),
[props.prefix],
)
snapshots.push(useObservable(observable, 'initial'))
return createElement(Fragment, null)
}
const {unmount, rerender} = render(createElement(ObservableComponent, {prefix: 'first'}))
act(() => subject.next('foo'))
act(() => subject.next(undefined))
act(() => subject.next('bar'))
// now change the prefix
rerender(createElement(ObservableComponent, {prefix: 'second'}))
act(() => subject.next('foo again'))
act(() => subject.next(undefined))
act(() => subject.next('bar again'))
expect(snapshots).toEqual([
'initial',
'first-foo',
undefined,
'first-bar',
'initial',
'second-foo again',
undefined,
'second-bar again',
])
unmount()
})