diff --git a/.gitignore b/.gitignore
index 2b0cce1..f1041cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,5 +2,4 @@
*.swp
node_modules
/dist
-/jotai
.vscode
diff --git a/__tests__/atomEffect.strict.test.ts b/__tests__/atomEffect.strict.test.ts
index 63da819..3a9615b 100644
--- a/__tests__/atomEffect.strict.test.ts
+++ b/__tests__/atomEffect.strict.test.ts
@@ -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'
@@ -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
@@ -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 }
@@ -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)
@@ -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)
@@ -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
@@ -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,
@@ -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)
@@ -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)
@@ -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)
@@ -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)
})
@@ -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,
@@ -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)
@@ -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 }
@@ -432,17 +414,17 @@ 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'
@@ -450,6 +432,10 @@ it('should batch synchronous updates as a single transaction', async () => {
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++
@@ -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++
@@ -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)
})
@@ -574,7 +551,6 @@ it('should abort the previous promise', async () => {
async function resolveAll() {
resolves.forEach((resolve) => resolve())
resolves.length = 0
- await delay(0)
}
function useTest() {
useAtomValue(effectAtom)
@@ -582,20 +558,19 @@ it('should abort the previous promise', async () => {
}
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])
@@ -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) => {
@@ -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)
})
diff --git a/__tests__/atomEffect.test.tsx b/__tests__/atomEffect.test.tsx
index f377d79..6629744 100644
--- a/__tests__/atomEffect.test.tsx
+++ b/__tests__/atomEffect.test.tsx
@@ -2,7 +2,7 @@ import React, { createElement, useEffect } from 'react'
import { act, render, renderHook, waitFor } from '@testing-library/react'
import { Provider, useAtom, useAtomValue, useSetAtom } from 'jotai/react'
import { atom, createStore, getDefaultStore } from 'jotai/vanilla'
-import { atomEffect } from '../src/atomEffect'
+import { atomEffect } from 'jotai-effect'
import {
ErrorBoundary,
assert,
@@ -11,52 +11,50 @@ import {
incrementLetter,
} from './test-utils'
-it('should run the effect on vanilla store', async () => {
- const store = getDefaultStore()
+it('should run the effect on vanilla store', () => {
+ const store = createStore().unstable_derive(
+ (getAtomState, setAtomState, ...rest) => [
+ getAtomState,
+ (atom, atomState) =>
+ void setAtomState(
+ atom,
+ Object.assign(atomState, {
+ label: atom.debugLabel,
+ })
+ ),
+ ...rest,
+ ]
+ )
const countAtom = atom(0)
+ countAtom.debugLabel = 'count'
const effectAtom = atomEffect((_, set) => {
set(countAtom, increment)
return () => {
set(countAtom, 0)
}
})
+ effectAtom.debugLabel = 'effect'
const unsub = store.sub(effectAtom, () => void 0)
- expect(store.get(countAtom)).toBe(0)
- await waitFor(() => expect(store.get(countAtom)).toBe(1))
- unsub()
- await waitFor(() => expect(store.get(countAtom)).toBe(0))
-})
-
-it('should not call effect if immediately unsubscribed', async () => {
- expect.assertions(1)
- const store = getDefaultStore()
- const effect = jest.fn()
- const effectAtom = atomEffect(effect)
- const unsub = store.sub(effectAtom, () => void 0)
+ expect(store.get(countAtom)).toBe(1)
unsub()
- expect(effect).not.toHaveBeenCalled()
+ expect(store.get(countAtom)).toBe(0)
})
-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)
- await waitFor(() => assert(hasRun && hasMounted))
// effect does not return a value
expect(result.current).toBe(undefined)
@@ -75,7 +73,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 }
@@ -99,11 +97,11 @@ it('should run the effect on mount and cleanup on unmount and whenever countAtom
return setCount
}
const { result, rerender, unmount } = renderHook(useTest)
- async function incrementCount() {
+ function incrementCount() {
const setCount = result.current
- await act(async () => setCount(increment))
+ setCount(increment)
}
- await waitFor(() => assert(didMount && effect.mount === 1))
+ waitFor(() => assert(didMount && effect.mount === 1))
// initial render should run the effect but not the cleanup
expect(effect.unmount).toBe(0)
@@ -114,13 +112,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)
@@ -138,7 +136,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(2)
const watchedAtom = atom(0)
let runCount = 0
@@ -150,17 +148,15 @@ it('should not cause infinite loops when effect updates the watched atom', async
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- const incrementWatched = async () => store.set(watchedAtom, increment)
- await delay(0)
+ const incrementWatched = () => store.set(watchedAtom, increment)
// initial render should run the effect once
- await waitFor(() => assert(runCount === 1))
expect(runCount).toBe(1)
// changing the value should run the effect again one time
- await incrementWatched()
+ incrementWatched()
expect(runCount).toBe(2)
})
-it('should not cause infinite loops when effect updates the watched atom asynchronous', async () => {
+it('should not cause infinite loops when effect updates the watched atom asynchronous', () => {
expect.assertions(1)
const watchedAtom = atom(0)
let runCount = 0
@@ -173,41 +169,32 @@ it('should not cause infinite loops when effect updates the watched atom asynchr
})
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- await delay(0)
- // initial render should run the effect once
- await waitFor(() => assert(runCount === 1))
-
// changing the value should run the effect again one time
store.set(watchedAtom, increment)
-
- await delay(0)
expect(runCount).toBe(2)
})
-it('should allow synchronous recursion with set.recurse for first run', async () => {
+it('should allow synchronous recursion with set.recurse for first run', () => {
expect.assertions(1)
let runCount = 0
const watchedAtom = atom(0)
- let done = false
const effectAtom = atomEffect((get, { recurse }) => {
const value = get(watchedAtom)
runCount++
if (value >= 3) {
- done = true
return
}
recurse(watchedAtom, increment)
})
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- await waitFor(() => assert(done))
expect({ runCount, watched: store.get(watchedAtom) }).toEqual({
- runCount: 4,
- watched: 3,
+ runCount: 4, // 2
+ watched: 3, // 2
})
})
-it('should allow synchronous recursion with set.recurse', async () => {
+it('should allow synchronous recursion with set.recurse', () => {
expect.assertions(2)
let runCount = 0
const watchedAtom = atom(0)
@@ -224,14 +211,12 @@ it('should allow synchronous recursion with set.recurse', async () => {
})
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- await delay(0)
store.set(watchedAtom, increment)
- await waitFor(() => assert(store.get(watchedAtom) === 5))
expect(store.get(watchedAtom)).toBe(5)
expect(runCount).toBe(6)
})
-it('should allow multiple synchronous recursion with set.recurse', async () => {
+it('should allow multiple synchronous recursion with set.recurse', () => {
expect.assertions(1)
let runCount = 0
const watchedAtom = atom(0)
@@ -249,16 +234,14 @@ it('should allow multiple synchronous recursion with set.recurse', async () => {
})
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- await delay(0)
store.set(watchedAtom, increment)
- await delay(0)
expect({ runCount, value: store.get(watchedAtom) }).toEqual({
runCount: 6,
value: 5,
})
})
-it('should batch updates during synchronous recursion with set.recurse', async () => {
+it('should batch updates during synchronous recursion with set.recurse', () => {
expect.assertions(2)
let runCount = 0
const lettersAtom = atom('a')
@@ -288,9 +271,7 @@ it('should batch updates during synchronous recursion with set.recurse', async (
})
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- await delay(0)
store.set(watchedAtom, increment)
- await delay(0)
expect(store.get(lettersAndNumbersAtom)).toEqual(['a0', 'b1'])
expect(runCount).toBe(4)
})
@@ -335,12 +316,12 @@ it('should allow asynchronous recursion with microtask delay with set.recurse',
})
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- await delay(500)
+ await waitFor(() => assert(store.get(watchedAtom) >= 3))
expect(store.get(watchedAtom)).toBe(3)
expect(runCount).toBe(4)
})
-it('should work with both set.recurse and set', async () => {
+it('should work with both set.recurse and set', () => {
expect.assertions(3)
let runCount = 0
const watchedAtom = atom(0)
@@ -358,14 +339,13 @@ it('should work with both set.recurse and set', async () => {
})
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- await waitFor(() => assert(store.get(countAtom) === 3))
expect(store.get(countAtom)).toBe(3)
expect(store.get(watchedAtom)).toBe(4)
expect(runCount).toBe(4)
})
-it('should disallow synchronous set.recurse in cleanup', async () => {
- expect.assertions(2)
+it('should disallow synchronous set.recurse in cleanup', () => {
+ expect.assertions(1)
const watchedAtom = atom(0)
const anotherAtom = atom(0)
let cleanup
@@ -379,18 +359,15 @@ it('should disallow synchronous set.recurse in cleanup', async () => {
})
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- await delay(0)
store.set(anotherAtom, increment)
- await delay(0)
- expect(store.get(watchedAtom)).toBe(0)
- expect(() => store.get(effectAtom)).toThrowError(
+ expect(() => store.set(anotherAtom, increment)).toThrowError(
'set.recurse is not allowed in cleanup'
)
})
// FIXME: is there a way to disallow asynchronous infinite loops in cleanup?
-it('should return value from set.recurse', async () => {
+it('should return value from set.recurse', () => {
expect.assertions(1)
const countAtom = atom(0)
const incrementCountAtom = atom(null, (get, set) => {
@@ -398,23 +375,20 @@ it('should return value from set.recurse', async () => {
return get(countAtom)
})
const results = [] as number[]
- let done = false
const effectAtom = atomEffect((get, { recurse }) => {
const value = get(countAtom)
if (value < 5) {
const result = recurse(incrementCountAtom)
results.unshift(result)
- done = true
return
}
})
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- await waitFor(() => assert(done))
expect(results).toEqual([1, 2, 3, 4, 5])
})
-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)
@@ -439,19 +413,19 @@ it('should conditionally run the effect and cleanup when effectAtom is unmounted
const { result } = renderHook(useTest)
const setBoolean = result.current
- const toggleBoolean = () => act(async () => setBoolean((prev) => !prev))
+ const toggleBoolean = () => 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)
})
@@ -522,6 +496,7 @@ describe('should correctly process synchronous updates to the same atom', () =>
// 2. incrementing count: count = 1
// 3. incrementing count: count = 2
// 4. incrementing count reruns the effect (batched): run = 2
+ // FIXME: understand why run-result: 3-2
effectIncrementCountBy: 0,
incrementCountBy: 2,
runs: [
@@ -560,6 +535,7 @@ describe('should correctly process synchronous updates to the same atom', () =>
// 4. incrementing count: count = 3
// 5. incrementing count reruns the effect (batched): run = 2
// 6. effect increments count: count = 4
+ // FIXME: understand why run-result: 3-5
effectIncrementCountBy: 1,
incrementCountBy: 2,
runs: [
@@ -597,6 +573,7 @@ describe('should correctly process synchronous updates to the same atom', () =>
// 4. incrementing count: count = 4
// 5. incrementing count reruns the effect (batched): run = 2
// 6. effect increments count by two: count = 6
+ // FIXME: understand why run-result: 3-8
effectIncrementCountBy: 2,
incrementCountBy: 2,
runs: [
@@ -654,8 +631,6 @@ it('should not batch effect setStates', async () => {
const { result } = renderHook(() => useSetAtom(triggerAtom))
const setTrigger = result.current
- await waitFor(() => assert(runCount.current === 1))
-
expect(valueResult.current).toBe(0)
expect(runCount.current).toBe(1)
@@ -664,7 +639,7 @@ it('should not batch effect setStates', async () => {
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'
@@ -685,23 +660,25 @@ it('should batch synchronous updates as a single transaction', async () => {
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- await waitFor(() => assert(!!runCount))
expect(runCount).toBe(1)
expect(store.get(lettersAndNumbersAtom)).toEqual(['a0'])
- await act(async () => {
- store.set(lettersAtom, incrementLetter)
- store.set(numbersAtom, increment)
+ const w = atom(null, (_get, set) => {
+ set(lettersAtom, incrementLetter)
+ set(numbersAtom, increment)
})
+ store.set(w)
expect(runCount).toBe(2)
expect(store.get(lettersAndNumbersAtom)).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 lettersAndNumbersAtom = atom(null, (_get, set) => {
+ set(lettersAtom, incrementLetter)
+ set(numbersAtom, increment)
+ })
let runCount = 0
const effectAtom = atomEffect((get) => {
runCount++
@@ -731,23 +708,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(lettersAndNumbersAtom)
}
const { result } = renderHook(useTest)
- 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)
})
@@ -790,7 +758,6 @@ it('should abort the previous promise', async () => {
async function resolveAll() {
resolves.forEach((resolve) => resolve())
resolves.length = 0
- await delay(0)
}
function useTest() {
useAtomValue(effectAtom)
@@ -798,20 +765,19 @@ it('should abort the previous promise', async () => {
}
const { result } = renderHook(useTest)
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])
@@ -835,10 +801,12 @@ it('should not infinite loop with nested atomEffects', async () => {
return () => ++metrics.unmounted
}
+ let delayedIncrement = false
const effectAtom = atomEffect((_get, set) => {
++metrics.runCount1
if (metrics.runCount1 > 1) throw new Error('infinite loop')
Promise.resolve().then(() => {
+ delayedIncrement = true
set(countAtom, increment)
})
})
@@ -856,7 +824,7 @@ it('should not infinite loop with nested atomEffects', async () => {
const store = getDefaultStore()
store.sub(effect2Atom, () => void 0)
- await waitFor(() => assert(!!metrics.runCount1))
+ await waitFor(() => assert(delayedIncrement))
if (!('dev4_get_mounted_atoms' in store)) return
const atomSet = new Set(store.dev4_get_mounted_atoms())
@@ -878,7 +846,7 @@ it('should not infinite loop with nested atomEffects', async () => {
})
})
-it('should not rerun with get.peek', async () => {
+it('should not rerun with get.peek', () => {
expect.assertions(1)
const countAtom = atom(0)
let runCount = 0
@@ -888,15 +856,12 @@ it('should not rerun with get.peek', async () => {
})
const store = getDefaultStore()
store.sub(effectAtom, () => void 0)
- await waitFor(() => assert(runCount === 1))
store.set(countAtom, increment)
- await delay(0)
expect(runCount).toBe(1)
})
it('should trigger the error boundary when an error is thrown', async () => {
expect.assertions(1)
-
const effectAtom = atomEffect((_get, _set) => {
throw new Error('effect error')
})
@@ -913,7 +878,13 @@ it('should trigger the error boundary when an error is thrown', async () => {
/>
)
}
- render(, { wrapper })
+ const originalConsoleError = console.error
+ try {
+ console.error = jest.fn()
+ render(, { wrapper })
+ } finally {
+ console.error = originalConsoleError
+ }
await waitFor(() => assert(didThrow))
expect(didThrow).toBe(true)
})
@@ -950,12 +921,17 @@ it('should trigger an error boundary when an error is thrown in a cleanup', asyn
)
}
render(, { wrapper })
- await delay(0)
- act(() => store.set(refreshAtom, increment))
+ const originalConsoleError = console.error
+ try {
+ console.error = jest.fn()
+ act(() => store.set(refreshAtom, increment))
+ } finally {
+ console.error = originalConsoleError
+ }
await waitFor(() => assert(didThrow))
})
-it('should not suspend the component', async () => {
+it('should not suspend the component', () => {
const countAtom = atom(0)
const watchCounterEffect = atomEffect((get) => {
get(countAtom)
diff --git a/__tests__/withAtomEffect.test.ts b/__tests__/withAtomEffect.test.ts
index 8813cdd..aff2926 100644
--- a/__tests__/withAtomEffect.test.ts
+++ b/__tests__/withAtomEffect.test.ts
@@ -1,9 +1,8 @@
-import { act, renderHook, waitFor } from '@testing-library/react'
+import { act, renderHook } from '@testing-library/react'
import { useAtomValue } from 'jotai/react'
import { atom, createStore } from 'jotai/vanilla'
import { atomEffect } from '../src/atomEffect'
import { withAtomEffect } from '../src/withAtomEffect'
-import { delay } from './test-utils'
describe('withAtomEffect', () => {
it('ensures readonly atoms remain readonly', () => {
@@ -29,7 +28,7 @@ describe('withAtomEffect', () => {
expect(store.get(enhancedAtom)).toBe(6)
})
- it('calls effect on initial use and on dependencies change of the base atom', async () => {
+ it('calls effect on initial use and on dependencies change of the base atom', () => {
const baseAtom = atom(0)
const effectMock = jest.fn()
const enhancedAtom = withAtomEffect(baseAtom, (get) => {
@@ -38,14 +37,12 @@ describe('withAtomEffect', () => {
})
const store = createStore()
store.sub(enhancedAtom, () => {})
- await Promise.resolve()
expect(effectMock).toHaveBeenCalledTimes(1)
store.set(enhancedAtom, 1)
- await Promise.resolve()
expect(effectMock).toHaveBeenCalledTimes(2)
})
- it('calls effect on initial use and on dependencies change of the enhanced atom', async () => {
+ it('calls effect on initial use and on dependencies change of the enhanced atom', () => {
const baseAtom = atom(0)
const effectMock = jest.fn()
const enhancedAtom = withAtomEffect(baseAtom, (get) => {
@@ -54,14 +51,12 @@ describe('withAtomEffect', () => {
})
const store = createStore()
store.sub(enhancedAtom, () => {})
- await Promise.resolve()
expect(effectMock).toHaveBeenCalledTimes(1)
store.set(enhancedAtom, 1)
- await Promise.resolve()
expect(effectMock).toHaveBeenCalledTimes(2)
})
- it('cleans up when the atom is no longer in use', async () => {
+ it('cleans up when the atom is no longer in use', () => {
const cleanupMock = jest.fn()
const baseAtom = atom(0)
const mountMock = jest.fn()
@@ -73,10 +68,8 @@ describe('withAtomEffect', () => {
})
const store = createStore()
const unsubscribe = store.sub(enhancedAtom, () => {})
- await Promise.resolve()
expect(mountMock).toHaveBeenCalledTimes(1)
unsubscribe()
- await Promise.resolve()
expect(cleanupMock).toHaveBeenCalledTimes(1)
})
@@ -88,21 +81,19 @@ describe('withAtomEffect', () => {
expect(enhancedAtom.read).not.toBe(read)
})
- it('does not cause infinite loops when it references itself', async () => {
+ it('does not cause infinite loops when it references itself', () => {
const countWithEffectAtom = withAtomEffect(atom(0), (get, set) => {
get(countWithEffectAtom)
set(countWithEffectAtom, (v) => v + 1)
})
const store = createStore()
store.sub(countWithEffectAtom, () => {})
- await Promise.resolve()
expect(store.get(countWithEffectAtom)).toBe(1)
store.set(countWithEffectAtom, (v) => ++v)
- await Promise.resolve()
expect(store.get(countWithEffectAtom)).toBe(3)
})
- it('can change the effect of the enhanced atom', async () => {
+ it('can change the effect of the enhanced atom', () => {
const baseAtom = atom(0)
const effectA = jest.fn((get) => {
get(enhancedAtom)
@@ -111,22 +102,19 @@ describe('withAtomEffect', () => {
expect(enhancedAtom.effect).toBe(effectA)
const store = createStore()
store.sub(enhancedAtom, () => {})
- await Promise.resolve()
effectA.mockClear()
store.set(enhancedAtom, (v) => ++v)
- await Promise.resolve()
expect(effectA).toHaveBeenCalledTimes(1)
effectA.mockClear()
const effectB = jest.fn((get) => get(baseAtom))
enhancedAtom.effect = effectB
expect(enhancedAtom.effect).toBe(effectB)
store.set(enhancedAtom, (v) => ++v)
- await Promise.resolve()
expect(effectA).not.toHaveBeenCalled()
expect(effectB).toHaveBeenCalledTimes(1)
})
- it('runs the cleanup function the same number of times as the effect function', async () => {
+ it('runs the cleanup function the same number of times as the effect function', () => {
const baseAtom = atom(0)
const effectMock = jest.fn()
const cleanupMock = jest.fn()
@@ -139,20 +127,17 @@ describe('withAtomEffect', () => {
})
const store = createStore()
const unsub = store.sub(enhancedAtom, () => {})
- await Promise.resolve()
expect(effectMock).toHaveBeenCalledTimes(1)
expect(cleanupMock).toHaveBeenCalledTimes(0)
store.set(enhancedAtom, 1)
- await Promise.resolve()
expect(effectMock).toHaveBeenCalledTimes(2)
expect(cleanupMock).toHaveBeenCalledTimes(1)
unsub()
- await Promise.resolve()
expect(effectMock).toHaveBeenCalledTimes(2)
expect(cleanupMock).toHaveBeenCalledTimes(2)
})
- it('runs the cleanup function the same number of times as the effect function in React', async () => {
+ it('runs the cleanup function the same number of times as the effect function in React', () => {
const baseAtom = atom(0)
const effectMock1 = jest.fn()
const cleanupMock1 = jest.fn()
@@ -183,21 +168,21 @@ describe('withAtomEffect', () => {
useAtomValue(enhancedAtom2, { store })
}
const { unmount } = renderHook(Test)
- await waitFor(() => expect(effectMock1).toHaveBeenCalledTimes(1))
- await waitFor(() => expect(effectMock2).toHaveBeenCalledTimes(1))
+ expect(effectMock1).toHaveBeenCalledTimes(1)
+ expect(effectMock2).toHaveBeenCalledTimes(1)
expect(cleanupMock1).toHaveBeenCalledTimes(0)
expect(cleanupMock2).toHaveBeenCalledTimes(0)
act(() => store.set(baseAtom, 1))
- await waitFor(() => expect(effectMock1).toHaveBeenCalledTimes(2))
- await waitFor(() => expect(effectMock2).toHaveBeenCalledTimes(2))
+ expect(effectMock1).toHaveBeenCalledTimes(2)
+ expect(effectMock2).toHaveBeenCalledTimes(2)
expect(cleanupMock1).toHaveBeenCalledTimes(1)
expect(cleanupMock2).toHaveBeenCalledTimes(1)
act(unmount)
- await waitFor(() => expect(cleanupMock1).toHaveBeenCalledTimes(2))
- await waitFor(() => expect(cleanupMock2).toHaveBeenCalledTimes(2))
+ expect(cleanupMock1).toHaveBeenCalledTimes(2)
+ expect(cleanupMock2).toHaveBeenCalledTimes(2)
})
- it('calculates price and discount', async () => {
+ it('calculates price and discount', () => {
// https://github.com/pmndrs/jotai/discussions/2876
/*
How can be implemented an atom to hold either a value or a calculated value at the same time?
@@ -273,22 +258,18 @@ describe('withAtomEffect', () => {
const store = createStore()
store.sub(priceAndDiscount, () => void 0)
- await delay(0)
expect(store.get(priceAtom)).toBe(100) // value
expect(store.get(discountAtom)).toBe(0) // (100-100)/100*100 = 0)
store.set(discountAtom, 20)
- await delay(0)
expect(store.get(priceAtom)).toBe(80) // 100*(1-20/100) = 80)
expect(store.get(discountAtom)).toBe(20) // value
store.set(priceAtom, 50)
- await delay(0)
expect(store.get(priceAtom)).toBe(50) // value
expect(store.get(discountAtom)).toBe(50) // (100-50)/100*100 = 50)
store.set(unitPriceAtom, 200)
- await delay(0)
expect(store.get(priceAtom)).toBe(100) // 200*(1-50/100) = 100)
expect(store.get(discountAtom)).toBe(50) // (200-100)/200*100 = 50)
})
diff --git a/package.json b/package.json
index 65b60fe..76c0028 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"src",
"dist"
],
+ "packageManager": "pnpm@8.15.0",
"scripts": {
"compile": "microbundle build -f modern,umd --globals react=React",
"postcompile": "cp dist/index.modern.mjs dist/index.modern.js && cp dist/index.modern.mjs.map dist/index.modern.js.map",
@@ -75,7 +76,7 @@
"html-webpack-plugin": "^5.5.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
- "jotai": "2.10.3",
+ "jotai": "https://pkg.csb.dev/pmndrs/jotai/commit/3b435d79/jotai",
"microbundle": "^0.15.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.0.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1a4daf5..becf3fd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -60,8 +60,8 @@ devDependencies:
specifier: ^29.7.0
version: 29.7.0
jotai:
- specifier: 2.10.3
- version: 2.10.3(@types/react@18.2.25)(react@18.2.0)
+ specifier: https://pkg.csb.dev/pmndrs/jotai/commit/3b435d79/jotai
+ version: '@pkg.csb.dev/pmndrs/jotai/commit/3b435d79/jotai(@types/react@18.2.25)(react@18.2.0)'
microbundle:
specifier: ^0.15.1
version: 0.15.1
@@ -5873,22 +5873,6 @@ packages:
- ts-node
dev: true
- /jotai@2.10.3(@types/react@18.2.25)(react@18.2.0):
- resolution: {integrity: sha512-Nnf4IwrLhNfuz2JOQLI0V/AgwcpxvVy8Ec8PidIIDeRi4KCFpwTFIpHAAcU+yCgnw/oASYElq9UY0YdUUegsSA==}
- engines: {node: '>=12.20.0'}
- peerDependencies:
- '@types/react': '>=17.0.0'
- react: '>=17.0.0'
- peerDependenciesMeta:
- '@types/react':
- optional: true
- react:
- optional: true
- dependencies:
- '@types/react': 18.2.25
- react: 18.2.0
- dev: true
-
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
dev: true
@@ -8980,3 +8964,22 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
dev: true
+
+ '@pkg.csb.dev/pmndrs/jotai/commit/3b435d79/jotai(@types/react@18.2.25)(react@18.2.0)':
+ resolution: {tarball: https://pkg.csb.dev/pmndrs/jotai/commit/3b435d79/jotai}
+ id: '@pkg.csb.dev/pmndrs/jotai/commit/3b435d79/jotai'
+ name: jotai
+ version: 2.11.0
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=17.0.0'
+ react: '>=17.0.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ react:
+ optional: true
+ dependencies:
+ '@types/react': 18.2.25
+ react: 18.2.0
+ dev: true
diff --git a/src/atomEffect.ts b/src/atomEffect.ts
index 152a9b5..e530272 100644
--- a/src/atomEffect.ts
+++ b/src/atomEffect.ts
@@ -1,113 +1,178 @@
-import type { Atom, Getter, Setter } from 'jotai/vanilla'
+import type { Atom, Getter, Setter, createStore } from 'jotai/vanilla'
import { atom } from 'jotai/vanilla'
-type Cleanup = () => void
+type Store = ReturnType
+
+type GetAtomState = Parameters[0]>[0]
+
+type AtomState = NonNullable>
+
+type Batch = Parameters>[0]
+
+type AnyAtom = Atom
+
type GetterWithPeek = Getter & { peek: Getter }
+
type SetterWithRecurse = Setter & { recurse: Setter }
-export type Effect = Parameters[0]
-export type AtomWithEffect = Atom> = T & {
- effect: Effect
-}
+
+type Cleanup = () => void
+
+export type Effect = (
+ get: GetterWithPeek,
+ set: SetterWithRecurse
+) => void | Cleanup
+
type Ref = {
- /** inProgress */
+ /** epoch */
+ x: number
+ /** in progress */
i: number
+ /** recursing */
+ rc: boolean
/** mounted */
m: boolean
- /** promise */
- p: Promise | undefined
- /** pending error */
- e?: unknown
- /** cleanup */
- c: Cleanup | void
/** from cleanup */
fc: boolean
- /** is recursing */
- irc: boolean
- /** is refreshing */
- irf: boolean
- peek: Getter
- set: Setter
+ /** getter */
+ g: Getter
+ /** cleanup */
+ c?: Cleanup | void
+ /** pending error */
+ e?: unknown
+ /** run effect */
+ r: () => void
}
-export function atomEffect(
- effect: (get: GetterWithPeek, set: SetterWithRecurse) => void | Cleanup
-): AtomWithEffect {
+export function atomEffect(effect: Effect) {
const refreshAtom = atom(0)
- const refAtom = atom(
- () => ({ i: 0 }) as Ref,
- (get, set) => {
- const ref = get(refAtom)
- Object.assign(ref, { m: true, peek: get, set })
- set(refreshAtom, (c) => c + 1)
- return () => {
- ref.m = false
- cleanup(ref)
- throwPendingError(ref)
- }
- }
- )
- refAtom.onMount = (mount) => mount()
- const baseAtom = atom((get) => {
+
+ const refAtom = atom(() => ({ i: 0, x: 0 }) as Ref)
+
+ const internalAtom = atom((get) => {
get(refreshAtom)
const ref = get(refAtom)
- if (!ref.m || ref.irc || (ref.i && !ref.irf)) {
- return ref.p
- }
throwPendingError(ref)
- const currDeps = new Map, unknown>()
- const getter: GetterWithPeek = (a) => {
- const value = get(a)
- currDeps.set(a, value)
- return value
+ ref.g = get
+
+ if (ref.rc) {
+ // in-place recursive call
+ ref.r()
}
- getter.peek = ref.peek
- const setter: SetterWithRecurse = (...args) => {
+ return ++ref.x
+ })
+
+ internalAtom.unstable_onInit = (store) => {
+ const ref = store.get(refAtom)
+ ref.r = runEffect
+
+ function runEffect() {
+ if (!ref.m || (ref.i && !ref.rc)) {
+ return
+ }
+
+ const deps = new Map()
+
+ const getterWithPeek = ((a) => {
+ const value = ref.g!(a)
+ deps.set(a, value)
+ return value
+ }) as GetterWithPeek
+ getterWithPeek.peek = store.get
+
+ const setterWithRecurse = ((a, ...args) => {
+ try {
+ ++ref.i
+ return store.set(a, ...args)
+ } finally {
+ --ref.i
+ }
+ }) as SetterWithRecurse
+
+ setterWithRecurse.recurse = (a, ...args) => {
+ if (ref.fc) {
+ if (process.env.NODE_ENV !== 'production') {
+ throw new Error('set.recurse is not allowed in cleanup')
+ }
+ return undefined as any
+ }
+ try {
+ return setterWithRecurse(a, ...args)
+ } finally {
+ const depsChanged = Array.from(deps).some(areDifferent)
+ if (depsChanged) {
+ ref.rc = true
+ refresh()
+ ref.rc = false
+ }
+ }
+ }
+
try {
++ref.i
- return ref.set(...args)
+ cleanup()
+ ref.c = effectAtom.effect(getterWithPeek, setterWithRecurse)
+ } catch (e) {
+ ref.e = e
+ refresh()
} finally {
- Array.from(currDeps.keys(), get)
--ref.i
}
+
+ function areDifferent([a, v]: [Atom, unknown]) {
+ return getterWithPeek.peek(a) !== v
+ }
}
- setter.recurse = (anAtom, ...args) => {
- if (ref.fc) {
- if (process.env.NODE_ENV !== 'production') {
- throw new Error('set.recurse is not allowed in cleanup')
- }
- return undefined as any
+
+ const atomState = getAtomState(store, internalAtom)
+
+ const originalMountHook = atomState.h
+ atomState.h = (batch) => {
+ originalMountHook?.(batch)
+ if (atomState.m) {
+ ref.m = true
+ // effect on mount
+ scheduleListener(batch, runEffect)
+ } else {
+ ref.m = false
+ // cleanup on unmount
+ scheduleListener(batch, cleanup)
}
- try {
- ref.irc = true
- return ref.set(anAtom, ...args)
- } finally {
- ref.irc = false
- const depsChanged = Array.from(currDeps).some(areDifferent)
- if (depsChanged) {
- refresh(ref)
- }
+ }
+
+ const originalUpdateHook = atomState.u
+ atomState.u = (batch) => {
+ originalUpdateHook?.(batch)
+ // effect on update
+ scheduleListener(batch, runEffect)
+ }
+
+ function scheduleListener(batch: Batch, listener: () => void) {
+ if (!ref.rc) {
+ batch[0].add(listener)
}
}
- function areDifferent([a, v]: [Atom, unknown]) {
- return get(a) !== v
+
+ function refresh() {
+ store.set(refreshAtom, (v) => v + 1)
}
- ++ref.i
- function runEffect() {
+
+ function cleanup() {
+ if (typeof ref.c !== 'function') {
+ return
+ }
try {
- ref.irf = false
- if (!ref.m) return
- cleanup(ref)
- ref.c = effectAtom.effect(getter, setter)
- } catch (error) {
- ref.e = error
- refresh(ref)
+ ref.fc = true
+ ref.c()
+ } catch (e) {
+ ref.e = e
+ refresh()
} finally {
- ref.p = undefined
- --ref.i
+ ref.fc = false
+ delete ref.c
}
}
- return ref.irf ? runEffect() : (ref.p = Promise.resolve().then(runEffect))
- })
+ }
+
if (process.env.NODE_ENV !== 'production') {
function setLabel(atom: Atom, label: string) {
Object.defineProperty(atom, 'debugLabel', {
@@ -117,29 +182,15 @@ export function atomEffect(
}
setLabel(refreshAtom, 'refresh')
setLabel(refAtom, 'ref')
- setLabel(baseAtom, 'base')
+ setLabel(internalAtom, 'internal')
}
- const effectAtom = atom((get) => void get(baseAtom)) as AtomWithEffect
- effectAtom.effect = effect
+
+ const effectAtom = Object.assign(
+ atom((get) => get(internalAtom)),
+ { effect }
+ )
return effectAtom
- function refresh(ref: Ref) {
- try {
- ref.irf = true
- ref.set(refreshAtom, (c) => c + 1)
- } finally {
- ref.irf = false
- }
- }
- function cleanup(ref: Ref) {
- if (!ref.c) return
- try {
- ref.fc = true
- ref.c()
- } finally {
- ref.fc = false
- ref.c = undefined
- }
- }
+
function throwPendingError(ref: Ref) {
if ('e' in ref) {
const error = ref.e
@@ -148,3 +199,27 @@ export function atomEffect(
}
}
}
+
+const getAtomStateMap = new WeakMap()
+
+/**
+ * HACK: steal atomState to synchronously determine if
+ * the atom is mounted
+ * We return null to cause the buildStore(...args) to throw
+ * to abort creating a derived store
+ */
+function getAtomState(store: Store, atom: AnyAtom): AtomState {
+ let getAtomStateFn = getAtomStateMap.get(store)
+ if (!getAtomStateFn) {
+ try {
+ store.unstable_derive((...storeArgs) => {
+ getAtomStateFn = storeArgs[0]
+ return null as any
+ })
+ } catch {
+ // expect error
+ }
+ getAtomStateMap.set(store, getAtomStateFn!)
+ }
+ return getAtomStateFn!(atom)!
+}
diff --git a/src/withAtomEffect.ts b/src/withAtomEffect.ts
index 2792e7a..49e6c13 100644
--- a/src/withAtomEffect.ts
+++ b/src/withAtomEffect.ts
@@ -1,7 +1,9 @@
import type { Atom } from 'jotai/vanilla'
-import type { AtomWithEffect, Effect } from './atomEffect'
+import type { Effect } from './atomEffect'
import { atomEffect } from './atomEffect'
+type AtomWithEffect> = T & { effect: Effect }
+
export function withAtomEffect>(
targetAtom: T,
effect: Effect