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