Skip to content

Commit 7e1d828

Browse files
dai-shiPinpickle
andauthored
fix(utils): refactor loadable (#857)
* fix(utils): refactor loadable * add more test Co-authored-by: Christian Silver <Pinpickle@users.noreply.github.com>
1 parent c6d0a80 commit 7e1d828

2 files changed

Lines changed: 107 additions & 18 deletions

File tree

src/utils/loadable.ts

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,32 +11,57 @@ type Loadable<Value> =
1111
| { state: 'hasError'; error: unknown }
1212
| { state: 'hasData'; data: ResolveType<Value> }
1313

14+
const LOADING: Loadable<unknown> = { state: 'loading' }
15+
1416
export function loadable<Value>(anAtom: Atom<Value>): Atom<Loadable<Value>> {
1517
return memoizeAtom(() => {
16-
// TODO we should revisit this for a better solution than refAtom
17-
const refAtom = atom(() => ({} as { prev?: Loadable<Value> }))
18+
const loadableAtomCache = new WeakMap<
19+
Promise<void>,
20+
Atom<Loadable<Value>>
21+
>()
1822

19-
const derivedAtom = atom((get): Loadable<Value> => {
20-
const ref = get(refAtom)
21-
let curr = ref.prev
23+
const catchAtom = atom((get) => {
24+
let promise: Promise<void>
2225
try {
23-
const value = get(anAtom) as ResolveType<Value>
24-
if (curr?.state !== 'hasData' || !Object.is(curr.data, value)) {
25-
curr = { state: 'hasData', data: value }
26-
}
26+
const data = get(anAtom) as ResolveType<Value>
27+
const loadableAtom = atom({ state: 'hasData', data } as Loadable<Value>)
28+
return loadableAtom
2729
} catch (error) {
2830
if (error instanceof Promise) {
29-
if (curr?.state !== 'loading') {
30-
curr = { state: 'loading' }
31-
}
31+
promise = error
3232
} else {
33-
if (curr?.state !== 'hasError' || !Object.is(curr.error, error)) {
34-
curr = { state: 'hasError', error }
33+
const loadableAtom = atom({
34+
state: 'hasError',
35+
error,
36+
} as Loadable<Value>)
37+
return loadableAtom
38+
}
39+
}
40+
const cached = loadableAtomCache.get(promise)
41+
if (cached) {
42+
return cached
43+
}
44+
const loadableAtom = atom(
45+
LOADING as Loadable<Value>,
46+
async (get, set) => {
47+
try {
48+
const data: Value = await get(anAtom, { unstable_promise: true })
49+
set(loadableAtom, { state: 'hasData', data })
50+
} catch (error) {
51+
set(loadableAtom, { state: 'hasError', error })
3552
}
3653
}
54+
)
55+
loadableAtom.onMount = (init) => {
56+
init()
3757
}
38-
ref.prev = curr
39-
return curr as Loadable<Value>
58+
loadableAtomCache.set(promise, loadableAtom)
59+
return loadableAtom
60+
})
61+
62+
const derivedAtom = atom((get) => {
63+
const loadableAtom = get(catchAtom)
64+
return get(loadableAtom)
4065
})
4166

4267
return derivedAtom

tests/utils/loadable.test.tsx

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Suspense, useEffect } from 'react'
12
import { fireEvent, render } from '@testing-library/react'
23
import { Atom, atom } from 'jotai'
34
import { loadable, useAtomValue, useUpdateAtom } from 'jotai/utils'
@@ -130,13 +131,76 @@ it('loadable can recover from error', async () => {
130131
await findByText('Data: 6')
131132
})
132133

134+
it('loadable immediately resolves sync values', async () => {
135+
const syncAtom = atom(5)
136+
const effectCallback = jest.fn()
137+
138+
const { getByText } = render(
139+
<Provider>
140+
<LoadableComponent effectCallback={effectCallback} asyncAtom={syncAtom} />
141+
</Provider>
142+
)
143+
144+
getByText('Data: 5')
145+
expect(effectCallback.mock.calls).not.toContain(
146+
expect.objectContaining({ state: 'loading' })
147+
)
148+
expect(effectCallback).toHaveBeenLastCalledWith({ state: 'hasData', data: 5 })
149+
})
150+
151+
it('loadable can use resolved promises syncronously', async () => {
152+
const asyncAtom = atom(Promise.resolve(5))
153+
const effectCallback = jest.fn()
154+
155+
const ResolveAtomComponent = () => {
156+
useAtomValue(asyncAtom)
157+
158+
return <div>Ready</div>
159+
}
160+
161+
const { getByText, findByText, rerender } = render(
162+
<Provider>
163+
<Suspense fallback={null}>
164+
<ResolveAtomComponent />
165+
</Suspense>
166+
</Provider>
167+
)
168+
169+
await findByText('Ready')
170+
171+
rerender(
172+
<Provider>
173+
<LoadableComponent
174+
effectCallback={effectCallback}
175+
asyncAtom={asyncAtom}
176+
/>
177+
</Provider>
178+
)
179+
getByText('Data: 5')
180+
181+
expect(effectCallback.mock.calls).not.toContain(
182+
expect.objectContaining({ state: 'loading' })
183+
)
184+
expect(effectCallback).toHaveBeenLastCalledWith({ state: 'hasData', data: 5 })
185+
})
186+
133187
interface LoadableComponentProps {
134-
asyncAtom: Atom<Promise<number> | Promise<string>>
188+
asyncAtom: Atom<Promise<number> | Promise<string> | string | number>
189+
effectCallback?: (loadableValue: any) => void
135190
}
136191

137-
const LoadableComponent = ({ asyncAtom }: LoadableComponentProps) => {
192+
const LoadableComponent = ({
193+
asyncAtom,
194+
effectCallback,
195+
}: LoadableComponentProps) => {
138196
const value = useAtomValue(loadable(asyncAtom))
139197

198+
useEffect(() => {
199+
if (effectCallback) {
200+
effectCallback(value)
201+
}
202+
}, [value, effectCallback])
203+
140204
if (value.state === 'loading') {
141205
return <>Loading...</>
142206
}

0 commit comments

Comments
 (0)