Skip to content

Commit

Permalink
feat: Add broken implementation of loadable
Browse files Browse the repository at this point in the history
Not all tests are passing to show this
  • Loading branch information
Pinpickle committed Sep 9, 2021
1 parent de2c3d8 commit 6560615
Show file tree
Hide file tree
Showing 3 changed files with 206 additions and 0 deletions.
7 changes: 7 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,10 @@ export {
} from './utils/atomWithStorage'
export { atomWithObservable } from './utils/atomWithObservable'
export { useHydrateAtoms } from './utils/useHydrateAtoms'
export { loadable } from './utils/loadable'
export type {
Loadable,
LoadableError,
LoadableValue,
LoadableLoading,
} from './utils/loadable'
67 changes: 67 additions & 0 deletions src/utils/loadable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { atom } from 'jotai'
import type { Atom } from 'jotai'
import { getWeakCacheItem, setWeakCacheItem } from './weakCache'

const loadableAtomCache = new WeakMap()

export type Loadable<Value> =
| LoadableLoading
| LoadableError
| LoadableValue<Value>

export interface LoadableError {
state: 'hasError'
error: unknown
}

export interface LoadableLoading {
state: 'loading'
}

export interface LoadableValue<Value> {
state: 'hasData'
data: Value
}

const LOADING_LOADABLE: LoadableLoading = { state: 'loading' }

export function loadable<Value>(anAtom: Atom<Value>): Atom<Loadable<Value>> {
const deps: object[] = [anAtom]
const cachedAtom = getWeakCacheItem(loadableAtomCache, deps)
if (cachedAtom) {
return cachedAtom as Atom<Loadable<Value>>
}

const refAtom = atom<{ error: LoadableError | null }>({
error: null,
})

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

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

if (ref.error && Object.is(ref.error.error, error)) {
return ref.error
}

ref.error = {
state: 'hasError',
error,
}

return ref.error
}
})
setWeakCacheItem(loadableAtomCache, deps, derivedAtom)
return derivedAtom
}
132 changes: 132 additions & 0 deletions tests/utils/loadable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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 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 6560615

Please sign in to comment.