Skip to content

Commit

Permalink
feat(utils): Add loadable (#698)
Browse files Browse the repository at this point in the history
  • Loading branch information
Pinpickle committed Sep 13, 2021
1 parent 6d2ae55 commit c4a9962
Show file tree
Hide file tree
Showing 3 changed files with 202 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export {
} from './utils/atomWithStorage'
export { atomWithObservable } from './utils/atomWithObservable'
export { useHydrateAtoms } from './utils/useHydrateAtoms'
export { loadable } from './utils/loadable'
52 changes: 52 additions & 0 deletions src/utils/loadable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { atom } from 'jotai'
import type { Atom } from 'jotai'

const loadableAtomCache = new WeakMap<Atom<unknown>, Atom<Loadable<unknown>>>()
const errorLoadableCache = new WeakMap<object, Loadable<never>>()

type Loadable<Value> =
| { state: 'loading' }
| { state: 'hasError'; error: unknown }
| { state: 'hasData'; data: Value }

const LOADING_LOADABLE: Loadable<never> = { state: 'loading' }

export function loadable<Value>(anAtom: Atom<Value>): Atom<Loadable<Value>> {
const cachedAtom = loadableAtomCache.get(anAtom)
if (cachedAtom) {
return cachedAtom as Atom<Loadable<Value>>
}

const derivedAtom = atom((get): Loadable<Value> => {
try {
const value = get(anAtom)

return {
state: 'hasData',
data: value,
}
} catch (error) {
if (error instanceof Promise) {
return LOADING_LOADABLE
}

const cachedErrorLoadable = errorLoadableCache.get(error as Error)

if (cachedErrorLoadable) {
return cachedErrorLoadable
}

const errorLoadable: Loadable<never> = {
state: 'hasError',
error,
}

errorLoadableCache.set(error as Error, errorLoadable)
return errorLoadable
}
})

loadableAtomCache.set(anAtom, derivedAtom)

return derivedAtom
}
149 changes: 149 additions & 0 deletions tests/utils/loadable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { fireEvent, render } from '@testing-library/react'
import { Atom, atom } from '../../src/index'
import { loadable, useAtomValue, useUpdateAtom } from '../../src/utils'
import { getTestProvider } from '../testUtils'

const Provider = getTestProvider()

it('loadable turns suspense into values', async () => {
let resolveAsync!: (x: number) => void
const asyncAtom = atom(() => {
return new Promise<number>((resolve) => (resolveAsync = resolve))
})

const { findByText, getByText } = render(
<Provider>
<LoadableComponent asyncAtom={asyncAtom} />
</Provider>
)

getByText('Loading...')
resolveAsync(5)
await findByText('Data: 5')
})

it('loadable turns errors into values', async () => {
let rejectAsync!: (error: Error) => void
const asyncAtom = atom(() => {
return new Promise<number>((resolve, reject) => (rejectAsync = reject))
})

const { findByText, getByText } = render(
<Provider>
<LoadableComponent asyncAtom={asyncAtom} />
</Provider>
)

getByText('Loading...')
rejectAsync(new Error('An error occurred'))
await findByText('Error: An error occurred')
})

it('loadable turns primitive throws into values', async () => {
let rejectAsync!: (errorMessage: string) => void
const asyncAtom = atom(() => {
return new Promise<number>((resolve, reject) => (rejectAsync = reject))
})

const { findByText, getByText } = render(
<Provider>
<LoadableComponent asyncAtom={asyncAtom} />
</Provider>
)

getByText('Loading...')
rejectAsync('An error occurred')
await findByText('Error: An error occurred')
})

it('loadable goes back to loading after re-fetch', async () => {
let resolveAsync!: (x: number) => void
const refreshAtom = atom(0)
const asyncAtom = atom((get) => {
get(refreshAtom)
return new Promise<number>((resolve) => (resolveAsync = resolve))
})

const Refresh = () => {
const setRefresh = useUpdateAtom(refreshAtom)
return (
<>
<button onClick={() => setRefresh((value) => value + 1)}>
refresh
</button>
</>
)
}

const { findByText, getByText } = render(
<Provider>
<Refresh />
<LoadableComponent asyncAtom={asyncAtom} />
</Provider>
)

getByText('Loading...')
resolveAsync(5)
await findByText('Data: 5')
fireEvent.click(getByText('refresh'))
await findByText('Loading...')
resolveAsync(6)
await findByText('Data: 6')
})

it('loadable can recover from error', async () => {
let resolveAsync!: (x: number) => void
let rejectAsync!: (error: Error) => void
const refreshAtom = atom(0)
const asyncAtom = atom((get) => {
get(refreshAtom)
return new Promise<number>((resolve, reject) => {
resolveAsync = resolve
rejectAsync = reject
})
})

const Refresh = () => {
const setRefresh = useUpdateAtom(refreshAtom)
return (
<>
<button onClick={() => setRefresh((value) => value + 1)}>
refresh
</button>
</>
)
}

const { findByText, getByText } = render(
<Provider>
<Refresh />
<LoadableComponent asyncAtom={asyncAtom} />
</Provider>
)

getByText('Loading...')
rejectAsync(new Error('An error occurred'))
await findByText('Error: An error occurred')
fireEvent.click(getByText('refresh'))
await findByText('Loading...')
resolveAsync(6)
await findByText('Data: 6')
})

interface LoadableComponentProps {
asyncAtom: Atom<number | string>
}

const LoadableComponent = ({ asyncAtom }: LoadableComponentProps) => {
const value = useAtomValue(loadable(asyncAtom))

if (value.state === 'loading') {
return <>Loading...</>
}

if (value.state === 'hasError') {
return <>{String(value.error)}</>
}

return <>Data: {value.data}</>
}

0 comments on commit c4a9962

Please sign in to comment.