Skip to content

Commit

Permalink
feat: synchronous atomEffect
Browse files Browse the repository at this point in the history
  • Loading branch information
dmaskasky committed Jan 7, 2025
1 parent 8c5a803 commit 2101048
Show file tree
Hide file tree
Showing 8 changed files with 345 additions and 334 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@
*.swp
node_modules
/dist
/jotai
.vscode
116 changes: 45 additions & 71 deletions __tests__/atomEffect.strict.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StrictMode, useEffect } from 'react'
import { StrictMode } from 'react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useAtom, useAtomValue, useSetAtom } from 'jotai/react'
import { atom, getDefaultStore } from 'jotai/vanilla'
Expand All @@ -7,26 +7,21 @@ import { assert, delay, increment, incrementLetter } from './test-utils'

const wrapper = StrictMode

it('should run the effect on mount and cleanup on unmount once', async () => {
it('should run the effect on mount and cleanup on unmount once', () => {
expect.assertions(5)
const effect = { mount: 0, unmount: 0 }

let hasMounted = false
const effectAtom = atomEffect(() => {
effect.mount++
hasMounted = true
return () => {
effect.unmount++
}
})

let hasRun = false
function useTest() {
hasRun = true
return useAtomValue(effectAtom)
}
const { result, rerender, unmount } = renderHook(useTest, { wrapper })
await waitFor(() => assert(hasRun && hasMounted))
// effect does not return a value
expect(result.current).toBe(undefined)
// initial render should run the effect
Expand All @@ -44,7 +39,7 @@ it('should run the effect on mount and cleanup on unmount once', async () => {
expect(effect.unmount).toBe(1)
})

it('should run the effect on mount and cleanup on unmount and whenever countAtom changes', async () => {
it('should run the effect on mount and cleanup on unmount and whenever countAtom changes', () => {
expect.assertions(11)
const effect = { mount: 0, unmount: 0 }

Expand All @@ -58,21 +53,16 @@ it('should run the effect on mount and cleanup on unmount and whenever countAtom
}
})

let didMount = false
function useTest() {
const [count, setCount] = useAtom(countAtom)
const [_, setCount] = useAtom(countAtom)
useAtomValue(effectAtom)
useEffect(() => {
didMount = true
}, [count])
return setCount
}
const { result, rerender, unmount } = renderHook(useTest, { wrapper })
async function incrementCount() {
function incrementCount() {
const setCount = result.current
await act(async () => setCount(increment))
act(() => setCount(increment))
}
await waitFor(() => assert(didMount && effect.mount === 1))

// initial render should run the effect but not the cleanup
expect(effect.unmount).toBe(0)
Expand All @@ -83,13 +73,13 @@ it('should run the effect on mount and cleanup on unmount and whenever countAtom
expect(effect.unmount).toBe(0)
expect(effect.mount).toBe(1)

await incrementCount()
incrementCount()

// changing the value should run the effect again and the previous cleanup
expect(effect.unmount).toBe(1)
expect(effect.mount).toBe(2)

await incrementCount()
incrementCount()

// changing the value should run the effect again and the previous cleanup
expect(effect.unmount).toBe(2)
Expand All @@ -107,7 +97,7 @@ it('should run the effect on mount and cleanup on unmount and whenever countAtom
expect(effect.unmount).toBe(3)
})

it('should not cause infinite loops when effect updates the watched atom', async () => {
it('should not cause infinite loops when effect updates the watched atom', () => {
expect.assertions(1)
const watchedAtom = atom(0)
let runCount = 0
Expand All @@ -125,11 +115,8 @@ it('should not cause infinite loops when effect updates the watched atom', async
}
const { rerender } = renderHook(useTest, { wrapper })

// initial render should run the effect once
await waitFor(() => assert(runCount === 1))
// rerender should not run the effect again
rerender()
await delay(0)

expect({ runCount, watched: store.get(watchedAtom) }).toEqual({
runCount: 1,
Expand All @@ -151,7 +138,7 @@ it('should not cause infinite loops when effect updates the watched atom asynchr
function useTest() {
useAtom(effectAtom)
const setCount = useSetAtom(watchedAtom)
return () => act(async () => setCount(increment))
return () => act(() => setCount(increment))
}
const { result } = renderHook(useTest, { wrapper })
await delay(0)
Expand All @@ -164,7 +151,7 @@ it('should not cause infinite loops when effect updates the watched atom asynchr
expect(runCount).toBe(2)
})

it('should allow synchronous infinite loops with opt-in for first run', async () => {
it('should allow synchronous infinite loops with opt-in for first run', () => {
expect.assertions(1)
let runCount = 0
const watchedAtom = atom(0)
Expand All @@ -179,18 +166,16 @@ it('should allow synchronous infinite loops with opt-in for first run', async ()
function useTest() {
useAtom(effectAtom, { store })
const setCount = useSetAtom(watchedAtom, { store })
return () => act(async () => setCount(increment))
return () => act(() => setCount(increment))
}
const { result } = renderHook(useTest, { wrapper })
await delay(0)
await act(async () => result.current())
await delay(0)
act(() => result.current())
expect({ runCount, watched: store.get(watchedAtom) }).toEqual({
runCount: 7, // extra run for strict mode render
watched: 6,
})
})
it('should conditionally run the effect and cleanup when effectAtom is unmounted', async () => {
it('should conditionally run the effect and cleanup when effectAtom is unmounted', () => {
expect.assertions(6)

const booleanAtom = atom(false)
Expand All @@ -215,19 +200,19 @@ it('should conditionally run the effect and cleanup when effectAtom is unmounted

const { result } = renderHook(useTest, { wrapper })
const setBoolean = result.current
const toggleBoolean = () => act(async () => setBoolean((prev) => !prev))
const toggleBoolean = () => act(() => setBoolean((prev) => !prev))

// Initially the effectAtom should not run as booleanAtom is false
expect(effectRunCount).toBe(0)
expect(cleanupRunCount).toBe(0)

// Set booleanAtom to true, so effectAtom should run
await toggleBoolean()
toggleBoolean()
expect(effectRunCount).toBe(1)
expect(cleanupRunCount).toBe(0)

// Set booleanAtom to false, so effectAtom should cleanup
await toggleBoolean()
toggleBoolean()
expect(effectRunCount).toBe(1)
expect(cleanupRunCount).toBe(1)
})
Expand Down Expand Up @@ -384,7 +369,7 @@ describe('should correctly process synchronous updates to the same atom', () =>

it.each(solutions)(
'should correctly process synchronous updates when effectIncrementCountBy is $effectIncrementCountBy and incrementCountBy is $incrementCountBy',
async ({ effectIncrementCountBy, incrementCountBy, runs }) => {
({ effectIncrementCountBy, incrementCountBy, runs }) => {
expect.assertions(3)
const { result, runCount } = setup({
effectIncrementCountBy,
Expand All @@ -393,14 +378,11 @@ describe('should correctly process synchronous updates to the same atom', () =>

const [before, after] = runs

// initial value after $effectIncrementCountBy synchronous updates in the effect
await waitFor(() => assert(runCount.current === before.runCount))

// initial render should run the effect once
expect(runCount.current).toBe(before.runCount)

// perform $incrementCountBy synchronous updates
await act(async () => result.current.incrementCount())
act(() => result.current.incrementCount())

// final value after synchronous updates and rerun of the effect
expect(result.current.count).toBe(after.resultCount)
Expand All @@ -410,7 +392,7 @@ describe('should correctly process synchronous updates to the same atom', () =>
)
})

it('should not batch effect setStates', async () => {
it('should not batch effect setStates', () => {
expect.assertions(4)
const valueAtom = atom(0)
const runCount = { current: 0 }
Expand All @@ -432,24 +414,28 @@ it('should not batch effect setStates', async () => {
const { result } = renderHook(() => useSetAtom(triggerAtom), { wrapper })
const setTrigger = result.current

await waitFor(() => assert(runCount.current === 1))
waitFor(() => assert(runCount.current === 1))

expect(valueResult.current).toBe(0)
expect(runCount.current).toBe(1)

await act(async () => setTrigger((x) => !x))
act(() => setTrigger((x) => !x))
expect(valueResult.current).toBe(2)
expect(runCount.current).toBe(3) // <--- not batched (we would expect runCount to be 2 if batched)
})

it('should batch synchronous updates as a single transaction', async () => {
it('should batch synchronous updates as a single transaction', () => {
expect.assertions(4)
const lettersAtom = atom('a')
lettersAtom.debugLabel = 'lettersAtom'
const numbersAtom = atom(0)
numbersAtom.debugLabel = 'numbersAtom'
const lettersAndNumbersAtom = atom([] as string[])
lettersAndNumbersAtom.debugLabel = 'lettersAndNumbersAtom'
const setLettersAndNumbersAtom = atom(null, (_get, set) => {
set(lettersAtom, incrementLetter)
set(numbersAtom, increment)
})
let runCount = 0
const effectAtom = atomEffect((get, set) => {
runCount++
Expand All @@ -462,30 +448,30 @@ it('should batch synchronous updates as a single transaction', async () => {
})
function useTest() {
useAtomValue(effectAtom)
const setLetters = useSetAtom(lettersAtom)
const setNumbers = useSetAtom(numbersAtom)
const lettersAndNumbers = useAtomValue(lettersAndNumbersAtom)
return { setLetters, setNumbers, lettersAndNumbers }
const setLettersAndNumbers = useSetAtom(setLettersAndNumbersAtom)
return { setLettersAndNumbers, lettersAndNumbers }
}
const { result } = renderHook(useTest, { wrapper })
const { setLetters, setNumbers } = result.current
await waitFor(() => assert(!!runCount))
const { setLettersAndNumbers } = result.current
expect(runCount).toBe(1)
expect(result.current.lettersAndNumbers).toEqual(['a0'])
await act(async () => {
setLetters(incrementLetter)
setNumbers(increment)
})
act(setLettersAndNumbers)
expect(runCount).toBe(2)
expect(result.current.lettersAndNumbers).toEqual(['a0', 'b1'])
})

it('should run the effect once even if the effect is mounted multiple times', async () => {
it('should run the effect once even if the effect is mounted multiple times', () => {
expect.assertions(3)
const lettersAtom = atom('a')
lettersAtom.debugLabel = 'lettersAtom'
const numbersAtom = atom(0)
numbersAtom.debugLabel = 'numbersAtom'
const setLettersAndNumbersAtom = atom(null, (_get, set) => {
set(lettersAtom, incrementLetter)
set(numbersAtom, increment)
})
setLettersAndNumbersAtom.debugLabel = 'setLettersAndNumbersAtom'
let runCount = 0
const effectAtom = atomEffect((get) => {
runCount++
Expand Down Expand Up @@ -515,23 +501,14 @@ it('should run the effect once even if the effect is mounted multiple times', as
useAtomValue(derivedAtom2)
useAtomValue(derivedAtom3)
useAtomValue(derivedAtom4)
const setLetters = useSetAtom(lettersAtom)
const setNumbers = useSetAtom(numbersAtom)
return { setLetters, setNumbers }
return useSetAtom(setLettersAndNumbersAtom)
}
const { result } = renderHook(useTest, { wrapper })
const { setLetters, setNumbers } = result.current
await waitFor(() => assert(!!runCount))
const setLettersAndNumbers = result.current
expect(runCount).toBe(1)
await act(async () => {
setLetters(incrementLetter)
setNumbers(increment)
})
act(setLettersAndNumbers)
expect(runCount).toBe(2)
await act(async () => {
setLetters(incrementLetter)
setNumbers(increment)
})
act(setLettersAndNumbers)
expect(runCount).toBe(3)
})

Expand Down Expand Up @@ -574,28 +551,26 @@ it('should abort the previous promise', async () => {
async function resolveAll() {
resolves.forEach((resolve) => resolve())
resolves.length = 0
await delay(0)
}
function useTest() {
useAtomValue(effectAtom)
return useSetAtom(countAtom)
}
const { result } = renderHook(useTest, { wrapper })
const setCount = result.current
await waitFor(() => assert(!!runCount))

await resolveAll()
expect(runCount).toBe(1)
expect(abortedRuns).toEqual([])
expect(completedRuns).toEqual([0])

await act(async () => setCount(increment))
act(() => setCount(increment))
expect(runCount).toBe(2)
expect(abortedRuns).toEqual([])
expect(completedRuns).toEqual([0])

// aborted run
await act(async () => setCount(increment))
act(() => setCount(increment))
expect(runCount).toBe(3)
expect(abortedRuns).toEqual([1])
expect(completedRuns).toEqual([0])
Expand All @@ -606,7 +581,7 @@ it('should abort the previous promise', async () => {
expect(completedRuns).toEqual([0, 2])
})

it('should not run the effect when the effectAtom is unmounted', async () => {
it('should not run the effect when the effectAtom is unmounted', () => {
const countAtom = atom(0)
let runCount = 0
const effectAtom = atomEffect((get) => {
Expand All @@ -619,8 +594,7 @@ it('should not run the effect when the effectAtom is unmounted', async () => {
}
const { result } = renderHook(useTest, { wrapper })
const setCount = result.current
await delay(0)
expect(runCount).toBe(1)
await act(() => setCount(increment))
act(() => setCount(increment))
expect(runCount).toBe(2)
})
Loading

0 comments on commit 2101048

Please sign in to comment.