Skip to main content
Module

x/jotai/tests/utils/atomWithObservable.test.tsx

👻 Primitive and flexible state management for React
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734
import { Component, StrictMode, Suspense, useContext, useState } from 'react'import type { ReactElement, ReactNode } from 'react'import { act, fireEvent, render, waitFor } from '@testing-library/react'import { BehaviorSubject, Observable, Subject, delay, of } from 'rxjs'import { fromValue, makeSubject, pipe, toObservable } from 'wonka'import { atom, SECRET_INTERNAL_getScopeContext as getScopeContext, useAtom, useAtomValue, useSetAtom,} from 'jotai'import { atomWithObservable } from 'jotai/utils'import { getTestProvider } from '../testUtils'
const Provider = getTestProvider()
// This is only used to pass tests with unstable_enableVersionedWriteconst useRetryFromError = (scope?: symbol | string | number) => { const ScopeContext = getScopeContext(scope) const { r: retryFromError } = useContext(ScopeContext) return retryFromError || ((fn) => fn())}
beforeEach(() => { jest.useFakeTimers()})afterEach(() => { jest.runAllTimers() jest.useRealTimers()})
class ErrorBoundary extends Component< { children: ReactNode }, { error: string }> { state = { error: '', }
static getDerivedStateFromError(error: Error) { return { error: error.message } }
render() { if (this.state.error) { return <div>Error: {this.state.error}</div> } return this.props.children }}
it('count state', async () => { const observableAtom = atomWithObservable(() => of(1))
const Counter = () => { const [state] = useAtom(observableAtom)
return <>count: {state}</> }
const { findByText } = render( <StrictMode> <Provider> <Suspense fallback="loading"> <Counter /> </Suspense> </Provider> </StrictMode> )
await findByText('count: 1')})
it('writable count state', async () => { const subject = new BehaviorSubject(1) const observableAtom = atomWithObservable(() => subject)
const Counter = () => { const [state, dispatch] = useAtom(observableAtom) return ( <> count: {state} <button onClick={() => dispatch(9)}>button</button> </> ) }
const { findByText, getByText } = render( <StrictMode> <Provider> <Suspense fallback="loading"> <Counter /> </Suspense> </Provider> </StrictMode> )
await findByText('count: 1')
act(() => subject.next(2)) await findByText('count: 2')
fireEvent.click(getByText('button')) await findByText('count: 9') expect(subject.value).toBe(9)
expect(subject)})
it('writable count state without initial value', async () => { const subject = new Subject<number>() const observableAtom = atomWithObservable(() => subject)
const CounterValue = () => { const state = useAtomValue(observableAtom) return <>count: {state}</> }
const CounterButton = () => { const dispatch = useSetAtom(observableAtom) return <button onClick={() => dispatch(9)}>button</button> }
const { findByText, getByText } = render( <StrictMode> <Provider> <Suspense fallback="loading"> <CounterValue /> </Suspense> <CounterButton /> </Provider> </StrictMode> )
await findByText('loading')
fireEvent.click(getByText('button')) await findByText('count: 9')
act(() => subject.next(3)) await findByText('count: 3')})
it('writable count state with delayed value', async () => { const subject = new Subject<number>() const observableAtom = atomWithObservable(() => { const observable = of(1).pipe(delay(10 * 1000)) observable.subscribe((n) => subject.next(n)) return subject })
const Counter = () => { const [state, dispatch] = useAtom(observableAtom) return ( <> count: {state} <button onClick={() => { dispatch(9) }}> button </button> </> ) }
const { findByText, getByText } = render( <StrictMode> <Provider> <Suspense fallback="loading"> <Counter /> </Suspense> </Provider> </StrictMode> )
await findByText('loading') jest.runOnlyPendingTimers() await findByText('count: 1')
fireEvent.click(getByText('button')) await findByText('count: 9')})
it('only subscribe once per atom', async () => { const subject = new Subject() let totalSubscriptions = 0 const observable = new Observable((subscriber) => { totalSubscriptions++ subject.subscribe(subscriber) }) const observableAtom = atomWithObservable(() => observable)
const Counter = () => { const [state] = useAtom(observableAtom) return <>count: {state}</> }
const { findByText, rerender } = render( <Provider> <Suspense fallback="loading"> <Counter /> </Suspense> </Provider> ) await findByText('loading') act(() => subject.next(1)) await findByText('count: 1')
rerender(<div />) expect(totalSubscriptions).toEqual(1)
rerender( <Provider> <Suspense fallback="loading"> <Counter /> </Suspense> </Provider> ) act(() => subject.next(2)) await findByText('count: 2')
expect(totalSubscriptions).toEqual(2)})
it('cleanup subscription', async () => { const subject = new Subject() let activeSubscriptions = 0 const observable = new Observable((subscriber) => { activeSubscriptions++ subject.subscribe(subscriber) return () => { activeSubscriptions-- } }) const observableAtom = atomWithObservable(() => observable)
const Counter = () => { const [state] = useAtom(observableAtom) return <>count: {state}</> }
const { findByText, rerender } = render( <StrictMode> <Provider> <Suspense fallback="loading"> <Counter /> </Suspense> </Provider> </StrictMode> )
await findByText('loading')
act(() => subject.next(1)) await findByText('count: 1')
expect(activeSubscriptions).toEqual(1) rerender(<div />) await waitFor(() => expect(activeSubscriptions).toEqual(0))})
it('resubscribe on remount', async () => { const subject = new Subject<number>() const observableAtom = atomWithObservable(() => subject)
const Counter = () => { const [state] = useAtom(observableAtom) return <>count: {state}</> }
const Toggle = ({ children }: { children: ReactElement }) => { const [visible, setVisible] = useState(true) return ( <> {visible && children} <button onClick={() => setVisible(!visible)}>Toggle</button> </> ) }
const { findByText, getByText } = render( <StrictMode> <Provider> <Suspense fallback="loading"> <Toggle> <Counter /> </Toggle> </Suspense> </Provider> </StrictMode> )
await findByText('loading') act(() => subject.next(1)) await findByText('count: 1')
fireEvent.click(getByText('Toggle')) fireEvent.click(getByText('Toggle'))
act(() => subject.next(2)) await findByText('count: 2')})
it("count state with initialValue doesn't suspend", async () => { const subject = new Subject<number>() const observableAtom = atomWithObservable(() => subject, { initialValue: 5 })
const Counter = () => { const [state] = useAtom(observableAtom) return <>count: {state}</> }
const { findByText } = render( <StrictMode> <Provider> <Counter /> </Provider> </StrictMode> )
await findByText('count: 5')
act(() => subject.next(10))
await findByText('count: 10')})
it('writable count state with initialValue', async () => { const subject = new Subject<number>() const observableAtom = atomWithObservable(() => subject, { initialValue: 5 })
const Counter = () => { const [state, dispatch] = useAtom(observableAtom) return ( <> count: {state} <button onClick={() => dispatch(9)}>button</button> </> ) }
const { findByText, getByText } = render( <StrictMode> <Provider> <Suspense fallback="loading"> <Counter /> </Suspense> </Provider> </StrictMode> )
await findByText('count: 5') act(() => subject.next(1)) await findByText('count: 1')
fireEvent.click(getByText('button')) await findByText('count: 9')})
it('writable count state with error', async () => { const subject = new Subject<number>() const observableAtom = atomWithObservable(() => subject)
const Counter = () => { const [state, dispatch] = useAtom(observableAtom) return ( <> count: {state} <button onClick={() => dispatch(9)}>button</button> </> ) }
const { findByText } = render( <StrictMode> <Provider> <ErrorBoundary> <Suspense fallback="loading"> <Counter /> </Suspense> </ErrorBoundary> </Provider> </StrictMode> )
await findByText('loading')
act(() => subject.error(new Error('Test Error'))) await findByText('Error: Test Error')})
it('synchronous subscription with initial value', async () => { const observableAtom = atomWithObservable(() => of(1), { initialValue: 5 })
const Counter = () => { const [state] = useAtom(observableAtom) return <>count: {state}</> }
const { findByText } = render( <StrictMode> <Provider> <Counter /> </Provider> </StrictMode> )
await findByText('count: 1')})
it('synchronous subscription with BehaviorSubject', async () => { const observableAtom = atomWithObservable(() => new BehaviorSubject(1))
const Counter = () => { const [state] = useAtom(observableAtom) return <>count: {state}</> }
const { findByText } = render( <StrictMode> <Provider> <Counter /> </Provider> </StrictMode> )
await findByText('count: 1')})
it('synchronous subscription with already emitted value', async () => { const observableAtom = atomWithObservable(() => of(1))
const Counter = () => { const [state] = useAtom(observableAtom)
return <>count: {state}</> }
const { findByText } = render( <StrictMode> <Provider> <Counter /> </Provider> </StrictMode> )
await findByText('count: 1')})
it('with falsy initial value', async () => { const observableAtom = atomWithObservable(() => new Subject<number>(), { initialValue: 0, })
const Counter = () => { const [state] = useAtom(observableAtom) return <>count: {state}</> }
const { findByText } = render( <StrictMode> <Provider> <Counter /> </Provider> </StrictMode> )
await findByText('count: 0')})
it('with initially emitted undefined value', async () => { const subject = new Subject<number | undefined | null>() const observableAtom = atomWithObservable(() => subject)
const Counter = () => { const [state] = useAtom(observableAtom) return <>count: {state === undefined ? '-' : state}</> }
const { findByText } = render( <StrictMode> <Provider> <Suspense fallback="loading"> <Counter /> </Suspense> </Provider> </StrictMode> )
await findByText('loading') act(() => subject.next(undefined)) await findByText('count: -') act(() => subject.next(1)) await findByText('count: 1')})
it("don't omit values emitted between init and mount", async () => { const subject = new Subject<number>() const observableAtom = atomWithObservable(() => subject)
const Counter = () => { const [state, dispatch] = useAtom(observableAtom) return ( <> count: {state} <button onClick={() => { dispatch(9) }}> button </button> </> ) }
const { findByText, getByText } = render( <StrictMode> <Provider> <Suspense fallback="loading"> <Counter /> </Suspense> </Provider> </StrictMode> )
await findByText('loading') act(() => { subject.next(1) subject.next(2) }) await findByText('count: 2')
fireEvent.click(getByText('button')) await findByText('count: 9')})
describe('error handling', () => { class ErrorBoundary extends Component< { message?: string; retry?: () => void; children: ReactNode }, { hasError: boolean } > { constructor(props: { message?: string; children: ReactNode }) { super(props) this.state = { hasError: false } } static getDerivedStateFromError() { return { hasError: true } } render() { return this.state.hasError ? ( <div> {this.props.message || 'errored'} {this.props.retry && ( <button onClick={() => { this.props.retry?.() this.setState({ hasError: false }) }}> retry </button> )} </div> ) : ( this.props.children ) } }
it('can catch error in error boundary', async () => { const subject = new Subject<number>() const countAtom = atomWithObservable(() => subject)
const Counter = () => { const [count] = useAtom(countAtom) return ( <> <div>count: {count}</div> </> ) }
const { findByText } = render( <StrictMode> <Provider> <ErrorBoundary> <Suspense fallback="loading"> <Counter /> </Suspense> </ErrorBoundary> </Provider> </StrictMode> )
await findByText('loading') act(() => subject.error(new Error('Test Error'))) await findByText('errored') })
it('can recover from error with dependency', async () => { const baseAtom = atom(0) const countAtom = atomWithObservable((get) => { const base = get(baseAtom) if (base % 2 === 0) { const subject = new Subject<number>() const observable = of(1).pipe(delay(10 * 1000)) observable.subscribe(() => subject.error(new Error('Test Error'))) return subject } const observable = of(base).pipe(delay(10 * 1000)) return observable })
const Counter = () => { const [count] = useAtom(countAtom) const setBase = useSetAtom(baseAtom) return ( <> <div>count: {count}</div> <button onClick={() => setBase((v) => v + 1)}>next</button> </> ) }
const App = () => { const setBase = useSetAtom(baseAtom) const retryFromError = useRetryFromError() const retry = () => { retryFromError(() => { setBase((c) => c + 1) }) } return ( <ErrorBoundary retry={retry}> <Suspense fallback="loading"> <Counter /> </Suspense> </ErrorBoundary> ) }
const { findByText, getByText } = render( <StrictMode> <Provider> <App /> </Provider> </StrictMode> )
await findByText('loading') jest.runOnlyPendingTimers() await findByText('errored')
fireEvent.click(getByText('retry')) await findByText('loading') jest.runOnlyPendingTimers() await findByText('count: 1')
fireEvent.click(getByText('next')) await findByText('loading') jest.runOnlyPendingTimers() await findByText('errored')
fireEvent.click(getByText('retry')) await findByText('loading') jest.runOnlyPendingTimers() await findByText('count: 3') })})
describe('wonka', () => { it('count state', async () => { const source = fromValue(1) const observable = pipe(source, toObservable) const observableAtom = atomWithObservable(() => observable)
const Counter = () => { const [count] = useAtom(observableAtom) return <>count: {count}</> }
const { findByText } = render( <StrictMode> <Provider> <Suspense fallback="loading"> <Counter /> </Suspense> </Provider> </StrictMode> )
await findByText('count: 1') })
it('make subject', async () => { const subject = makeSubject<number>() const observable = pipe(subject.source, toObservable) const observableAtom = atomWithObservable(() => observable) const countAtom = atom( (get) => get(observableAtom), (_get, _set, nextValue: number) => { subject.next(nextValue) } )
const Counter = () => { const [count] = useAtom(countAtom) return <>count: {count}</> }
const Controls = () => { const setCount = useSetAtom(countAtom) return <button onClick={() => setCount(1)}>button</button> }
const { findByText, getByText } = render( <StrictMode> <Provider> <Controls /> <Suspense fallback="loading"> <Counter /> </Suspense> </Provider> </StrictMode> )
await findByText('loading')
fireEvent.click(getByText('button')) await findByText('count: 1') })})