Skip to content

Commit

Permalink
chore(utils): refactor atomWithObservable (#599)
Browse files Browse the repository at this point in the history
  • Loading branch information
dai-shi committed Jul 18, 2021
1 parent 17922d5 commit 45f18a2
Show file tree
Hide file tree
Showing 2 changed files with 104 additions and 45 deletions.
80 changes: 42 additions & 38 deletions src/utils/atomWithObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,69 +34,73 @@ export function atomWithObservable<TData>(
export function atomWithObservable<TData>(
createObservable: (get: Getter) => ObservableLike<TData> | SubjectLike<TData>
) {
type Result = { data: TData } | { error: unknown }
const observableResultAtom = atom((get) => {
let resolve: ((result: Result) => void) | null = null
let settlePromise: ((data: TData | null, err?: unknown) => void) | null =
null
let observable = createObservable(get)
if (observable[Symbol.observable]) {
observable = (
observable[Symbol.observable] as () => ObservableLike<TData>
)()
const returnsItself = observable[Symbol.observable]
if (returnsItself) {
observable = returnsItself()
}
const resultAtom = atom<Result | Promise<Result>>(
new Promise<Result>((r) => {
resolve = r
const dataAtom = atom<TData | Promise<TData>>(
new Promise<TData>((resolve, reject) => {
settlePromise = (data, err) => {
if (err) {
reject(err)
} else {
resolve(data as TData)
}
}
})
)
resultAtom.scope = observableAtom.scope
let setResult: (result: Result) => void = () => {
dataAtom.scope = observableAtom.scope
let setData: (data: TData | Promise<TData>) => void = () => {
throw new Error('setting data without mount')
}
const dataListener = (data: TData) => {
if (resolve) {
resolve({ data })
resolve = null
if (settlePromise) {
settlePromise(data)
settlePromise = null
if (subscription && !setData) {
subscription.unsubscribe()
subscription = null
}
} else {
setResult({ data })
setData(data)
}
}
const errorListener = (error: unknown) => {
if (resolve) {
resolve({ error })
resolve = null
if (settlePromise) {
settlePromise(null, error)
settlePromise = null
if (subscription && !setData) {
subscription.unsubscribe()
subscription = null
}
} else {
setResult({ error })
setData(Promise.reject<TData>(error))
}
}
let subscription: Subscription | null = null
subscription = observable.subscribe((data) => {
dataListener(data)
if (subscription && !setResult) {
subscription.unsubscribe()
subscription = null
}
}, errorListener)
if (!resolve) {
subscription = observable.subscribe(dataListener, errorListener)
if (!settlePromise) {
subscription.unsubscribe()
subscription = null
}
resultAtom.onMount = (update) => {
setResult = update
if (!subscription)
dataAtom.onMount = (update) => {
setData = update
if (!subscription) {
subscription = observable.subscribe(dataListener, errorListener)
return () => subscription && subscription.unsubscribe()
}
return () => subscription?.unsubscribe()
}
return { resultAtom, observable }
return { dataAtom, observable }
})
const observableAtom = atom(
(get) => {
observableResultAtom.scope = observableAtom.scope
const { resultAtom } = get(observableResultAtom)
const result = get(resultAtom)
if ('error' in result) {
throw result.error
}
return result.data
const { dataAtom } = get(observableResultAtom)
return get(dataAtom)
},
(get, _set, data: TData) => {
observableResultAtom.scope = observableAtom.scope
Expand Down
69 changes: 62 additions & 7 deletions tests/utils/atomWithObservable.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC, Suspense } from 'react'
import { FC, Suspense, Component } from 'react'
import { fireEvent, render } from '@testing-library/react'
import { getTestProvider } from '../testUtils'
import { useAtom } from '../../src/index'
Expand All @@ -7,6 +7,31 @@ import { Observable, Subject } from 'rxjs'

const Provider = getTestProvider()

class ErrorBoundary extends Component<
{ message?: string },
{ hasError: boolean }
> {
constructor(props: { message?: string }) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError() {
return { hasError: true }
}
render() {
return this.state.hasError ? (
<div>
<div>{this.props.message || 'errored'}</div>
<button onClick={() => this.setState({ hasError: false })}>
retry
</button>
</div>
) : (
this.props.children
)
}
}

it('count state', async () => {
const observableAtom = atomWithObservable(
() =>
Expand All @@ -16,14 +41,14 @@ it('count state', async () => {
)

const Counter: FC = () => {
const [state$] = useAtom(observableAtom)
const [state] = useAtom(observableAtom)

return <>count: {state$}</>
return <>count: {state}</>
}

const { findByText } = render(
<Provider>
<Suspense fallback="loading...">
<Suspense fallback="loading">
<Counter />
</Suspense>
</Provider>
Expand All @@ -46,26 +71,56 @@ it('writable count state', async () => {
})

const Counter: FC = () => {
const [state$, dispatch] = useAtom(observableAtom)
const [state, dispatch] = useAtom(observableAtom)

return (
<>
count: {state$}
count: {state}
<button onClick={() => dispatch(9)}>button</button>
</>
)
}

const { findByText, getByText } = render(
<Provider>
<Suspense fallback="loading...">
<Suspense fallback="loading">
<Counter />
</Suspense>
</Provider>
)

await findByText('loading')
await findByText('count: 1')

fireEvent.click(getByText('button'))
await findByText('count: 9')
})

// FIXME we would like to support retry
it.skip('count state with error', async () => {
const myObservable = new Observable<number>((subscriber) => {
subscriber.error('err1')
subscriber.next(1)
})
const observableAtom = atomWithObservable(() => myObservable)

const Counter: React.FC = () => {
const [state] = useAtom(observableAtom)

return <div>count: {state}</div>
}

const { findByText, getByText } = render(
<Provider>
<ErrorBoundary>
<Suspense fallback="loading">
<Counter />
</Suspense>
</ErrorBoundary>
</Provider>
)

await findByText('errored')
fireEvent.click(getByText('retry'))
await findByText('count: 1')
})

1 comment on commit 45f18a2

@vercel
Copy link

@vercel vercel bot commented on 45f18a2 Jul 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.