Skip to content

Commit

Permalink
Feature: waitFor utilities (pmndrs#358)
Browse files Browse the repository at this point in the history
* wip: waitForAll

* feat: waitForAll

* wip returning a record from waitForAll

* feat: use weak cache to keep waitForAll atom reference

* Add possibly for records in waitFor

* Cleanup types

* fix types, improve tests

* fix: do not cache atoms with named

* Add error handling test

* Fix test and timers

* Add docs

* Fix header

* chore remove unused import

* Return promise.all

* Update size snapshot

* chore: remove unused/uneffective atom cache

* Update docs with csb

Co-authored-by: daishi <[email protected]>
Co-authored-by: Mathis Møller <[email protected]>
  • Loading branch information
3 people authored Mar 28, 2021
1 parent 3bd4ab0 commit 7fcabcf
Show file tree
Hide file tree
Showing 5 changed files with 361 additions and 7 deletions.
14 changes: 7 additions & 7 deletions .size-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@
}
},
"utils.module.js": {
"bundled": 8382,
"minified": 3699,
"gzipped": 1481,
"bundled": 9840,
"minified": 4505,
"gzipped": 1767,
"treeshaked": {
"rollup": {
"code": 28,
"import_statements": 28
},
"webpack": {
"code": 1091
"code": 1103
}
}
},
Expand Down Expand Up @@ -103,9 +103,9 @@
"gzipped": 3483
},
"utils.js": {
"bundled": 11579,
"minified": 5650,
"gzipped": 2098
"bundled": 13231,
"minified": 6571,
"gzipped": 2372
},
"devtools.js": {
"bundled": 2394,
Expand Down
76 changes: 76 additions & 0 deletions docs/api/utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,3 +493,79 @@ const App = () => (

export default App
```
## waitForAll
Sometimes you have multiple async atoms in your components:
```tsx
const dogsAtom = atom(async (get) => {
const response = await fetch('/dogs')
return await response.json()
})
const catsAtom = atom(async (get) => {
const response = await fetch('/cats')
return await response.json()
})

const App = () => {
const [dogs] = useAtom(dogsAtom)
const [cats] = useAtom(catsAtom)
// ...
}
```
However, this will start fetching one at the time, which is not optimal - It would be better if we can start fetching both as soon as possible.
The `waitForAll` utility is a concurrency helper, which allows us to evaluate multiple async atoms:
```tsx
const dogsAtom = atom(async (get) => {
const response = await fetch('/dogs')
return await response.json()
})
const catsAtom = atom(async (get) => {
const response = await fetch('/cats')
return await response.json()
})

const App = () => {
const [[dogs, cats]] = useAtom(waitForAll([dogsAtom, catsAtom]))
// or ...
const [dogs, cats] = useAtomValue(waitForAll([dogsAtom, catsAtom]))
// ...
}
```
You can also use `waitForAll` inside an atom - It's also possible to name them for readability:
```tsx
const dogsAtom = atom(async (get) => {
const response = await fetch('/dogs')
return await response.json()
})
const catsAtom = atom(async (get) => {
const response = await fetch('/cats')
return await response.json()
})

const animalsAtom = atom((get) => {
return get(
waitForAll({
dogs: dogsAtom,
cats: catsAtom,
})
)
})

const App = () => {
const [{ dogs, cats }] = useAtom(animalsAtom)
// or ...
const { dogs, cats } = useAtomValue(animalsAtom)
// ...
}
```
### Codesandbox
https://codesandbox.io/s/react-typescript-forked-krwsv?file=/src/App.tsx
1 change: 1 addition & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export { useAtomCallback } from './utils/useAtomCallback'
export { freezeAtom, atomFrozenInDev } from './utils/freezeAtom'
export { splitAtom } from './utils/splitAtom'
export { atomWithDefault } from './utils/atomWithDefault'
export { waitForAll } from './utils/waitForAll'
53 changes: 53 additions & 0 deletions src/utils/waitForAll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Atom, atom } from 'jotai'

export function waitForAll<Values extends Record<string, unknown>>(
atoms: { [K in keyof Values]: Atom<Values[K]> }
): Atom<Values>

export function waitForAll<Values extends unknown[]>(
atoms: { [K in keyof Values]: Atom<Values[K]> }
): Atom<Values>

export function waitForAll<Values extends Record<string, unknown> | unknown[]>(
atoms: { [K in keyof Values]: Atom<Values[K]> }
) {
const derivedAtom = atom((get) => {
const promises: Promise<unknown>[] = []
const values = unwrapAtoms(atoms).map((anAtom, index) => {
try {
return get(anAtom)
} catch (e) {
if (e instanceof Promise) {
promises[index] = e
} else {
throw e
}
}
})
if (promises.length) {
return Promise.all(promises)
}
return wrapResults(atoms, values)
})
return derivedAtom
}

const unwrapAtoms = <Values extends Record<string, unknown> | unknown[]>(
atoms: { [K in keyof Values]: Atom<Values[K]> }
): Atom<unknown>[] =>
Array.isArray(atoms)
? atoms
: Object.getOwnPropertyNames(atoms).map(
(key) => (atoms as Record<string, Atom<unknown>>)[key]
)

const wrapResults = <Values extends Record<string, unknown> | unknown[]>(
atoms: { [K in keyof Values]: Atom<Values[K]> },
results: unknown[]
): unknown[] | Record<string, unknown> =>
Array.isArray(atoms)
? results
: Object.getOwnPropertyNames(atoms).reduce(
(out, key, idx) => ({ ...out, [key]: results[idx] }),
{}
)
224 changes: 224 additions & 0 deletions tests/utils/waitForAll.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import React, { Fragment, StrictMode, Suspense } from 'react'
import { render } from '@testing-library/react'
import { Provider as ProviderOrig, atom, useAtom } from '../../src/index'
import { waitForAll } from '../../src/utils'

const Provider = process.env.PROVIDER_LESS_MODE ? Fragment : ProviderOrig

const consoleError = console.error
beforeEach(() => {
console.error = jest.fn()
})
afterEach(() => {
console.error = consoleError
})

jest.useFakeTimers()

class ErrorBoundary extends React.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>{this.props.message || 'errored'}</div>
) : (
this.props.children
)
}
}

it('waits for two async atoms', async () => {
let isAsyncAtomRunning = false
let isAnotherAsyncAtomRunning = false
const asyncAtom = atom(async () => {
isAsyncAtomRunning = true
await new Promise((resolve) => {
setTimeout(() => {
isAsyncAtomRunning = false
resolve(true)
}, 10)
})
return 1
})
const anotherAsyncAtom = atom(async () => {
isAnotherAsyncAtomRunning = true
await new Promise((resolve) => {
setTimeout(() => {
isAnotherAsyncAtomRunning = false
resolve(true)
}, 10)
})
return '2'
})

const Counter: React.FC = () => {
const [[num1, num2]] = useAtom(waitForAll([asyncAtom, anotherAsyncAtom]))
return (
<>
<div>num1: {num1}</div>
<div>num2: {num2}</div>
</>
)
}

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

await findByText('loading')
expect(isAsyncAtomRunning).toBe(true)
expect(isAnotherAsyncAtomRunning).toBe(true)

jest.runOnlyPendingTimers()

await findByText('num1: 1')
await findByText('num2: 2')
expect(isAsyncAtomRunning).toBe(false)
expect(isAnotherAsyncAtomRunning).toBe(false)
})

it('can use named atoms in derived atom', async () => {
let isAsyncAtomRunning = false
let isAnotherAsyncAtomRunning = false
const asyncAtom = atom(async () => {
isAsyncAtomRunning = true
await new Promise((resolve) => {
setTimeout(() => {
isAsyncAtomRunning = false
resolve(true)
}, 10)
})
return 1
})
const anotherAsyncAtom = atom(async () => {
isAnotherAsyncAtomRunning = true
await new Promise((resolve) => {
setTimeout(() => {
isAnotherAsyncAtomRunning = false
resolve(true)
}, 10)
})
return 'a'
})

const combinedWaitingAtom = atom((get) => {
const { num, str } = get(
waitForAll({
num: asyncAtom,
str: anotherAsyncAtom,
})
)
return { num: num * 2, str: str.toUpperCase() }
})

const Counter: React.FC = () => {
const [{ num, str }] = useAtom(combinedWaitingAtom)
return (
<>
<div>num: {num}</div>
<div>str: {str}</div>
</>
)
}

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

await findByText('loading')
expect(isAsyncAtomRunning).toBe(true)
expect(isAnotherAsyncAtomRunning).toBe(true)

jest.runOnlyPendingTimers()

await findByText('num: 2')
await findByText('str: A')
expect(isAsyncAtomRunning).toBe(false)
expect(isAnotherAsyncAtomRunning).toBe(false)
})

it('can handle errors', async () => {
let isAsyncAtomRunning = false
let isErrorAtomRunning = false
const asyncAtom = atom(async () => {
isAsyncAtomRunning = true
await new Promise((resolve) => {
setTimeout(() => {
isAsyncAtomRunning = false
resolve(true)
}, 10)
})
return 1
})
const errorAtom = atom(async () => {
isErrorAtomRunning = true
await new Promise((_, reject) => {
setTimeout(() => {
isErrorAtomRunning = false
reject('Charlotte')
}, 10)
})
return 'a'
})

const combinedWaitingAtom = atom((get) => {
return get(
waitForAll({
num: asyncAtom,
error: errorAtom,
})
)
})

const Counter: React.FC = () => {
const [{ num, error }] = useAtom(combinedWaitingAtom)
return (
<>
<div>num: {num}</div>
<div>str: {error}</div>
</>
)
}

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

await findByText('loading')
expect(isAsyncAtomRunning).toBe(true)
expect(isErrorAtomRunning).toBe(true)

jest.runOnlyPendingTimers()

await findByText('errored')
expect(isAsyncAtomRunning).toBe(false)
expect(isErrorAtomRunning).toBe(false)
})

0 comments on commit 7fcabcf

Please sign in to comment.