From 212a4bf12bbe347d16ef4415e62fd0f8a3bf1601 Mon Sep 17 00:00:00 2001 From: David Maskasky Date: Tue, 10 Sep 2024 13:03:31 -0700 Subject: [PATCH] local copy of jotai tests --- __tests__/derive/00_derive.ts | 12 + .../derive/baseTests/react/abortable.test.tsx | 223 ++++ .../derive/baseTests/react/async.test.tsx | 1153 +++++++++++++++++ .../derive/baseTests/react/async2.test.tsx | 369 ++++++ .../derive/baseTests/react/basic.test.tsx | 1003 ++++++++++++++ .../baseTests/react/dependency.test.tsx | 1041 +++++++++++++++ .../derive/baseTests/react/error.test.tsx | 569 ++++++++ .../derive/baseTests/react/items.test.tsx | 203 +++ .../derive/baseTests/react/onmount.test.tsx | 520 ++++++++ .../baseTests/react/optimization.test.tsx | 273 ++++ .../derive/baseTests/react/provider.test.tsx | 80 ++ .../baseTests/react/useAtomValue.test.tsx | 30 + .../baseTests/react/useSetAtom.test.tsx | 119 ++ .../baseTests/react/utils/types.test.tsx | 34 + .../react/utils/useAtomCallback.test.tsx | 175 +++ .../react/utils/useHydrateAtoms.test.tsx | 319 +++++ .../react/utils/useReducerAtom.test.tsx | 123 ++ .../react/utils/useResetAtom.test.tsx | 178 +++ .../react/vanilla-utils/atomFamily.test.tsx | 286 ++++ .../vanilla-utils/atomWithDefault.test.tsx | 206 +++ .../vanilla-utils/atomWithReducer.test.tsx | 85 ++ .../vanilla-utils/atomWithRefresh.test.tsx | 119 ++ .../react/vanilla-utils/freezeAtom.test.tsx | 79 ++ .../react/vanilla-utils/loadable.test.tsx | 307 +++++ .../react/vanilla-utils/selectAtom.test.tsx | 131 ++ .../react/vanilla-utils/splitAtom.test.tsx | 555 ++++++++ .../derive/baseTests/vanilla/basic.test.tsx | 66 + .../baseTests/vanilla/dependency.test.tsx | 276 ++++ .../derive/baseTests/vanilla/store.test.tsx | 582 +++++++++ .../baseTests/vanilla/storedev.test.tsx | 99 ++ .../vanilla/unstable_derive.test.tsx | 304 +++++ .../vanilla/utils/atomFamily.test.ts | 95 ++ .../vanilla/utils/atomWithLazy.test.ts | 40 + .../baseTests/vanilla/utils/loadable.test.ts | 20 + .../baseTests/vanilla/utils/unwrap.test.ts | 140 ++ __tests__/derive/derivedStore.ts | 19 + __tests__/derive/types.ts | 63 + package.json | 4 +- 38 files changed, 9898 insertions(+), 2 deletions(-) create mode 100644 __tests__/derive/00_derive.ts create mode 100644 __tests__/derive/baseTests/react/abortable.test.tsx create mode 100644 __tests__/derive/baseTests/react/async.test.tsx create mode 100644 __tests__/derive/baseTests/react/async2.test.tsx create mode 100644 __tests__/derive/baseTests/react/basic.test.tsx create mode 100644 __tests__/derive/baseTests/react/dependency.test.tsx create mode 100644 __tests__/derive/baseTests/react/error.test.tsx create mode 100644 __tests__/derive/baseTests/react/items.test.tsx create mode 100644 __tests__/derive/baseTests/react/onmount.test.tsx create mode 100644 __tests__/derive/baseTests/react/optimization.test.tsx create mode 100644 __tests__/derive/baseTests/react/provider.test.tsx create mode 100644 __tests__/derive/baseTests/react/useAtomValue.test.tsx create mode 100644 __tests__/derive/baseTests/react/useSetAtom.test.tsx create mode 100644 __tests__/derive/baseTests/react/utils/types.test.tsx create mode 100644 __tests__/derive/baseTests/react/utils/useAtomCallback.test.tsx create mode 100644 __tests__/derive/baseTests/react/utils/useHydrateAtoms.test.tsx create mode 100644 __tests__/derive/baseTests/react/utils/useReducerAtom.test.tsx create mode 100644 __tests__/derive/baseTests/react/utils/useResetAtom.test.tsx create mode 100644 __tests__/derive/baseTests/react/vanilla-utils/atomFamily.test.tsx create mode 100644 __tests__/derive/baseTests/react/vanilla-utils/atomWithDefault.test.tsx create mode 100644 __tests__/derive/baseTests/react/vanilla-utils/atomWithReducer.test.tsx create mode 100644 __tests__/derive/baseTests/react/vanilla-utils/atomWithRefresh.test.tsx create mode 100644 __tests__/derive/baseTests/react/vanilla-utils/freezeAtom.test.tsx create mode 100644 __tests__/derive/baseTests/react/vanilla-utils/loadable.test.tsx create mode 100644 __tests__/derive/baseTests/react/vanilla-utils/selectAtom.test.tsx create mode 100644 __tests__/derive/baseTests/react/vanilla-utils/splitAtom.test.tsx create mode 100644 __tests__/derive/baseTests/vanilla/basic.test.tsx create mode 100644 __tests__/derive/baseTests/vanilla/dependency.test.tsx create mode 100644 __tests__/derive/baseTests/vanilla/store.test.tsx create mode 100644 __tests__/derive/baseTests/vanilla/storedev.test.tsx create mode 100644 __tests__/derive/baseTests/vanilla/unstable_derive.test.tsx create mode 100644 __tests__/derive/baseTests/vanilla/utils/atomFamily.test.ts create mode 100644 __tests__/derive/baseTests/vanilla/utils/atomWithLazy.test.ts create mode 100644 __tests__/derive/baseTests/vanilla/utils/loadable.test.ts create mode 100644 __tests__/derive/baseTests/vanilla/utils/unwrap.test.ts create mode 100644 __tests__/derive/derivedStore.ts create mode 100644 __tests__/derive/types.ts diff --git a/__tests__/derive/00_derive.ts b/__tests__/derive/00_derive.ts new file mode 100644 index 0000000..3063182 --- /dev/null +++ b/__tests__/derive/00_derive.ts @@ -0,0 +1,12 @@ +import { atom } from '../../jotai'; +import { createStore } from './derivedStore'; + +it('calls subscribe callback when state changes', () => { + const countAtom = atom(1); + const store = createStore(); + const updateSpy = jest.fn((num) => num); + store.sub(countAtom, () => updateSpy(store.get(countAtom))); + store.set(countAtom, 2); + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(updateSpy).toHaveBeenLastCalledWith(2); +}); diff --git a/__tests__/derive/baseTests/react/abortable.test.tsx b/__tests__/derive/baseTests/react/abortable.test.tsx new file mode 100644 index 0000000..2963ebc --- /dev/null +++ b/__tests__/derive/baseTests/react/abortable.test.tsx @@ -0,0 +1,223 @@ +import { StrictMode, Suspense, useState } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtomValue, useSetAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; + +describe('abortable atom test', () => { + it('can abort with signal.aborted', async () => { + const countAtom = atom(0); + let abortedCount = 0; + const resolve: (() => void)[] = []; + const derivedAtom = atom(async (get, { signal }) => { + const count = get(countAtom); + await new Promise((r) => resolve.push(r)); + if (signal.aborted) { + ++abortedCount; + } + return count; + }); + + const Component = () => { + const count = useAtomValue(derivedAtom); + return
count: {count}
; + }; + + const Controls = () => { + const setCount = useSetAtom(countAtom); + return ( + <> + + + ); + }; + + const { findByText, getByText } = render( + + + + + + , + ); + + await findByText('loading'); + + resolve.splice(0).forEach((fn) => fn()); + await findByText('count: 0'); + expect(abortedCount).toBe(0); + + await userEvent.click(getByText('button')); + await userEvent.click(getByText('button')); + resolve.splice(0).forEach((fn) => fn()); + await findByText('count: 2'); + + expect(abortedCount).toBe(1); + + await userEvent.click(getByText('button')); + resolve.splice(0).forEach((fn) => fn()); + await findByText('count: 3'); + expect(abortedCount).toBe(1); + }); + + it('can abort with event listener', async () => { + const countAtom = atom(0); + let abortedCount = 0; + const resolve: (() => void)[] = []; + const derivedAtom = atom(async (get, { signal }) => { + const count = get(countAtom); + const callback = () => { + ++abortedCount; + }; + signal.addEventListener('abort', callback); + await new Promise((r) => resolve.push(r)); + signal.removeEventListener('abort', callback); + return count; + }); + + const Component = () => { + const count = useAtomValue(derivedAtom); + return
count: {count}
; + }; + + const Controls = () => { + const setCount = useSetAtom(countAtom); + return ( + <> + + + ); + }; + + const { findByText, getByText } = render( + + + + + + , + ); + + await findByText('loading'); + resolve.splice(0).forEach((fn) => fn()); + await findByText('count: 0'); + + expect(abortedCount).toBe(0); + + await userEvent.click(getByText('button')); + await userEvent.click(getByText('button')); + resolve.splice(0).forEach((fn) => fn()); + await findByText('count: 2'); + + expect(abortedCount).toBe(1); + + await userEvent.click(getByText('button')); + resolve.splice(0).forEach((fn) => fn()); + await findByText('count: 3'); + + expect(abortedCount).toBe(1); + }); + + it('does not abort on unmount', async () => { + const countAtom = atom(0); + let abortedCount = 0; + const resolve: (() => void)[] = []; + const derivedAtom = atom(async (get, { signal }) => { + const count = get(countAtom); + await new Promise((r) => resolve.push(r)); + if (signal.aborted) { + ++abortedCount; + } + return count; + }); + + const Component = () => { + const count = useAtomValue(derivedAtom); + return
count: {count}
; + }; + + const Parent = () => { + const setCount = useSetAtom(countAtom); + const [show, setShow] = useState(true); + return ( + <> + {show ? : 'hidden'} + + + + ); + }; + + const { findByText, getByText } = render( + + + + + , + ); + + await findByText('loading'); + + resolve.splice(0).forEach((fn) => fn()); + await findByText('count: 0'); + expect(abortedCount).toBe(0); + + await userEvent.click(getByText('button')); + await userEvent.click(getByText('toggle')); + + await findByText('hidden'); + + resolve.splice(0).forEach((fn) => fn()); + await waitFor(() => expect(abortedCount).toBe(0)); + }); + + it('throws aborted error (like fetch)', async () => { + const countAtom = atom(0); + const resolve: (() => void)[] = []; + const derivedAtom = atom(async (get, { signal }) => { + const count = get(countAtom); + await new Promise((r) => resolve.push(r)); + if (signal.aborted) { + throw new Error('aborted'); + } + return count; + }); + + const Component = () => { + const count = useAtomValue(derivedAtom); + return
count: {count}
; + }; + + const Controls = () => { + const setCount = useSetAtom(countAtom); + return ( + <> + + + ); + }; + + const { findByText, getByText } = render( + + + + + + , + ); + + await findByText('loading'); + + resolve.splice(0).forEach((fn) => fn()); + await findByText('count: 0'); + + await userEvent.click(getByText('button')); + await userEvent.click(getByText('button')); + resolve.splice(0).forEach((fn) => fn()); + await findByText('count: 2'); + + await userEvent.click(getByText('button')); + resolve.splice(0).forEach((fn) => fn()); + await findByText('count: 3'); + }); +}); diff --git a/__tests__/derive/baseTests/react/async.test.tsx b/__tests__/derive/baseTests/react/async.test.tsx new file mode 100644 index 0000000..ca65f4a --- /dev/null +++ b/__tests__/derive/baseTests/react/async.test.tsx @@ -0,0 +1,1153 @@ +import { StrictMode, Suspense, useEffect, useRef } from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; +import type { Atom } from '../../../../jotai/vanilla'; + +const useCommitCount = () => { + const commitCountRef = useRef(1); + useEffect(() => { + commitCountRef.current += 1; + }); + return commitCountRef.current; +}; + +it('does not show async stale result', async () => { + const countAtom = atom(0); + let resolve2 = () => {}; + const asyncCountAtom = atom(async (get) => { + await new Promise((r) => (resolve2 = r)); + return get(countAtom); + }); + + const committed: number[] = []; + + let resolve1 = () => {}; + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const onClick = async () => { + setCount((c) => c + 1); + await new Promise((r) => (resolve1 = r)); + setCount((c) => c + 1); + }; + return ( + <> +
count: {count}
+ + + ); + }; + + const DelayedCounter = () => { + const [delayedCount] = useAtom(asyncCountAtom); + useEffect(() => { + committed.push(delayedCount); + }); + return
delayedCount: {delayedCount}
; + }; + + const { getByText, findByText } = render( + <> + + + + + , + ); + + await findByText('loading'); + resolve1(); + resolve2(); + await waitFor(() => { + getByText('count: 0'); + getByText('delayedCount: 0'); + }); + expect(committed).toEqual([0]); + + await userEvent.click(getByText('button')); + await findByText('loading'); + await act(async () => { + resolve1(); + resolve2(); + await Promise.resolve(); + resolve2(); + }); + await waitFor(() => { + getByText('count: 2'); + getByText('delayedCount: 2'); + }); + expect(committed).toEqual([0, 2]); +}); + +it('does not show async stale result on derived atom', async () => { + const countAtom = atom(0); + let resolve = () => {}; + const asyncAlwaysNullAtom = atom(async (get) => { + get(countAtom); + await new Promise((r) => (resolve = r)); + return null; + }); + const derivedAtom = atom((get) => get(asyncAlwaysNullAtom)); + + const DisplayAsyncValue = () => { + const [asyncValue] = useAtom(asyncAlwaysNullAtom); + + return
async value: {JSON.stringify(asyncValue)}
; + }; + + const DisplayDerivedValue = () => { + const [derivedValue] = useAtom(derivedAtom); + return
derived value: {JSON.stringify(derivedValue)}
; + }; + + const Test = () => { + const [count, setCount] = useAtom(countAtom); + return ( +
+
count: {count}
+ loading async value
}> + + + loading derived value}> + + + + + ); + }; + + const { getByText, queryByText } = render( + + + , + ); + + await waitFor(() => { + getByText('count: 0'); + getByText('loading async value'); + getByText('loading derived value'); + }); + resolve(); + await waitFor(() => { + expect(queryByText('loading async value')).toBeNull(); + expect(queryByText('loading derived value')).toBeNull(); + }); + await waitFor(() => { + getByText('async value: null'); + getByText('derived value: null'); + }); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 1'); + getByText('loading async value'); + getByText('loading derived value'); + }); + resolve(); + await waitFor(() => { + expect(queryByText('loading async value')).toBeNull(); + expect(queryByText('loading derived value')).toBeNull(); + }); + await waitFor(() => { + getByText('async value: null'); + getByText('derived value: null'); + }); +}); + +it('works with async get with extra deps', async () => { + const countAtom = atom(0); + const anotherAtom = atom(-1); + let resolve = () => {}; + const asyncCountAtom = atom(async (get) => { + get(anotherAtom); + await new Promise((r) => (resolve = r)); + return get(countAtom); + }); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const DelayedCounter = () => { + const [delayedCount] = useAtom(asyncCountAtom); + return
delayedCount: {delayedCount}
; + }; + + const { getByText, findByText } = render( + + + + + + , + ); + + await findByText('loading'); + resolve(); + await waitFor(() => { + getByText('count: 0'); + getByText('delayedCount: 0'); + }); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve(); + await waitFor(() => { + getByText('count: 1'); + getByText('delayedCount: 1'); + }); +}); + +it('reuses promises on initial read', async () => { + let invokeCount = 0; + let resolve = () => {}; + const asyncAtom = atom(async () => { + invokeCount += 1; + await new Promise((r) => (resolve = r)); + return 'ready'; + }); + + const Child = () => { + const [str] = useAtom(asyncAtom); + return
{str}
; + }; + + const { findByText, findAllByText } = render( + + + + + + , + ); + + await findByText('loading'); + resolve(); + await findAllByText('ready'); + expect(invokeCount).toBe(1); +}); + +it('uses multiple async atoms at once', async () => { + const resolve: (() => void)[] = []; + const someAtom = atom(async () => { + await new Promise((r) => resolve.push(r)); + return 'ready'; + }); + const someAtom2 = atom(async () => { + await new Promise((r) => resolve.push(r)); + return 'ready2'; + }); + + const Component = () => { + const [some] = useAtom(someAtom); + const [some2] = useAtom(someAtom2); + return ( + <> +
+ {some} {some2} +
+ + ); + }; + + const { getByText, findByText } = render( + + + + + , + ); + + await findByText('loading'); + await waitFor(() => { + resolve.splice(0).forEach((fn) => fn()); + getByText('ready ready2'); + }); +}); + +it('uses async atom in the middle of dependency chain', async () => { + const countAtom = atom(0); + let resolve = () => {}; + const asyncCountAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)); + return get(countAtom); + }); + const delayedCountAtom = atom((get) => get(asyncCountAtom)); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const [delayedCount] = useAtom(delayedCountAtom); + return ( + <> +
+ count: {count}, delayed: {delayedCount} +
+ + + ); + }; + + const { getByText, findByText } = render( + + + + + , + ); + + await findByText('loading'); + resolve(); + await findByText('count: 0, delayed: 0'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve(); + await findByText('count: 1, delayed: 1'); +}); + +it('updates an async atom in child useEffect on remount without setTimeout', async () => { + const toggleAtom = atom(true); + const countAtom = atom(0); + const asyncCountAtom = atom( + async (get) => get(countAtom), + async (get, set) => set(countAtom, get(countAtom) + 1), + ); + + const Counter = () => { + const [count, incCount] = useAtom(asyncCountAtom); + useEffect(() => { + incCount(); + }, [incCount]); + return
count: {count}
; + }; + + const Parent = () => { + const [toggle, setToggle] = useAtom(toggleAtom); + return ( + <> + + {toggle ? :
no child
} + + ); + }; + + const { getByText, findByText } = render( + <> + + + + , + ); + + await findByText('count: 0'); + await findByText('count: 1'); + + await userEvent.click(getByText('button')); + await findByText('no child'); + + await userEvent.click(getByText('button')); + await findByText('count: 2'); +}); + +it('updates an async atom in child useEffect on remount', async () => { + const toggleAtom = atom(true); + const countAtom = atom(0); + const resolve: (() => void)[] = []; + const asyncCountAtom = atom( + async (get) => { + await new Promise((r) => resolve.push(r)); + return get(countAtom); + }, + async (get, set) => { + await new Promise((r) => resolve.push(r)); + set(countAtom, get(countAtom) + 1); + }, + ); + + const Counter = () => { + const [count, incCount] = useAtom(asyncCountAtom); + useEffect(() => { + incCount(); + }, [incCount]); + return
count: {count}
; + }; + + const Parent = () => { + const [toggle, setToggle] = useAtom(toggleAtom); + return ( + <> + + {toggle ? :
no child
} + + ); + }; + + const { getByText, findByText } = render( + <> + + + + , + ); + + await findByText('loading'); + + act(() => resolve.splice(0).forEach((fn) => fn())); + await findByText('count: 0'); + + await act(async () => { + resolve.splice(0).forEach((fn) => fn()); + await new Promise((r) => setTimeout(r)); // wait for a tick + resolve.splice(0).forEach((fn) => fn()); + }); + await findByText('count: 1'); + + await userEvent.click(getByText('button')); + await findByText('no child'); + + await userEvent.click(getByText('button')); + await act(async () => { + resolve.splice(0).forEach((fn) => fn()); + await new Promise((r) => setTimeout(r)); // wait for a tick + resolve.splice(0).forEach((fn) => fn()); + }); + await findByText('count: 2'); +}); + +it('async get and useEffect on parent', async () => { + const countAtom = atom(0); + const asyncAtom = atom(async (get) => { + const count = get(countAtom); + if (!count) return 'none'; + return 'resolved'; + }); + + const AsyncComponent = () => { + const [text] = useAtom(asyncAtom); + return
text: {text}
; + }; + + const Parent = () => { + const [count, setCount] = useAtom(countAtom); + useEffect(() => { + setCount((c) => c + 1); + }, [setCount]); + return ( + <> +
count: {count}
+ + + + ); + }; + + const { getByText, findByText } = render( + <> + + + + , + ); + + await findByText('loading'); + await waitFor(() => { + getByText('count: 1'); + getByText('text: resolved'); + }); +}); + +it('async get with another dep and useEffect on parent', async () => { + const countAtom = atom(0); + const derivedAtom = atom((get) => get(countAtom)); + const asyncAtom = atom(async (get) => { + const count = get(derivedAtom); + if (!count) return 'none'; + return count; + }); + + const AsyncComponent = () => { + const [count] = useAtom(asyncAtom); + return
async: {count}
; + }; + + const Parent = () => { + const [count, setCount] = useAtom(countAtom); + useEffect(() => { + setCount((c) => c + 1); + }, [setCount]); + return ( + <> +
count: {count}
+ + + + ); + }; + + const { getByText, findByText } = render( + <> + + + + , + ); + + await findByText('loading'); + await waitFor(() => { + getByText('count: 1'); + getByText('async: 1'); + }); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 2'); + getByText('async: 2'); + }); +}); + +it('set promise atom value on write (#304)', async () => { + const countAtom = atom(Promise.resolve(0)); + let resolve = () => {}; + const asyncAtom = atom(null, (get, set, _arg) => { + set( + countAtom, + Promise.resolve(get(countAtom)).then( + (c) => new Promise((r) => (resolve = () => r(c + 1))), + ), + ); + }); + + const Counter = () => { + const [count] = useAtom(countAtom); + return
count: {count * 1}
; + }; + + const Parent = () => { + const [, dispatch] = useAtom(asyncAtom); + return ( + <> + + + + ); + }; + + const { getByText, findByText } = render( + + + + + , + ); + + await findByText('loading'); + await findByText('count: 0'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve(); + await findByText('count: 1'); +}); + +it('uses async atom double chain (#306)', async () => { + const countAtom = atom(0); + let resolve = () => {}; + const asyncCountAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)); + return get(countAtom); + }); + const delayedCountAtom = atom(async (get) => { + return get(asyncCountAtom); + }); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const [delayedCount] = useAtom(delayedCountAtom); + return ( + <> +
+ count: {count}, delayed: {delayedCount} +
+ + + ); + }; + + const { getByText, findByText } = render( + + + + + , + ); + + await findByText('loading'); + resolve(); + await findByText('count: 0, delayed: 0'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve(); + await findByText('count: 1, delayed: 1'); +}); + +it('uses an async atom that depends on another async atom', async () => { + let resolve = () => {}; + const asyncAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)); + get(anotherAsyncAtom); + return 1; + }); + const anotherAsyncAtom = atom(async () => { + return 2; + }); + + const Counter = () => { + const [num] = useAtom(asyncAtom); + return
num: {num}
; + }; + + const { findByText } = render( + + + + + , + ); + + await findByText('loading'); + resolve(); + await findByText('num: 1'); +}); + +it('a derived atom from a newly created async atom (#351)', async () => { + const countAtom = atom(1); + const atomCache = new Map>>(); + const getAsyncAtom = (n: number) => { + if (!atomCache.has(n)) { + atomCache.set( + n, + atom(async () => { + return n + 10; + }), + ); + } + return atomCache.get(n) as Atom>; + }; + const derivedAtom = atom((get) => get(getAsyncAtom(get(countAtom)))); + + const Counter = () => { + const [, setCount] = useAtom(countAtom); + const [derived] = useAtom(derivedAtom); + return ( + <> +
+ derived: {derived}, commits: {useCommitCount()} +
+ + + ); + }; + + const { getByText, findByText } = render( + <> + + + + , + ); + + await findByText('loading'); + await findByText('derived: 11, commits: 1'); + + // The use of fireEvent is required to reproduce the issue + fireEvent.click(getByText('button')); + await findByText('loading'); + await findByText('derived: 12, commits: 2'); + + // The use of fireEvent is required to reproduce the issue + fireEvent.click(getByText('button')); + await findByText('loading'); + await findByText('derived: 13, commits: 3'); +}); + +it('Handles synchronously invoked async set (#375)', async () => { + const loadingAtom = atom(false); + const documentAtom = atom(undefined); + let resolve = () => {}; + const loadDocumentAtom = atom(null, (_get, set) => { + const fetch = async () => { + set(loadingAtom, true); + const response = await new Promise( + (r) => (resolve = () => r('great document')), + ); + set(documentAtom, response); + set(loadingAtom, false); + }; + fetch(); + }); + + const ListDocuments = () => { + const [loading] = useAtom(loadingAtom); + const [document] = useAtom(documentAtom); + const [, loadDocument] = useAtom(loadDocumentAtom); + + useEffect(() => { + loadDocument(); + }, [loadDocument]); + + return ( + <> + {loading &&
loading
} + {!loading &&
{document}
} + + ); + }; + + const { findByText } = render( + + + , + ); + + await findByText('loading'); + resolve(); + await findByText('great document'); +}); + +it('async write self atom', async () => { + let resolve = () => {}; + const countAtom = atom(0, async (get, set, _arg) => { + set(countAtom, get(countAtom) + 1); + await new Promise((r) => (resolve = r)); + set(countAtom, -1); + }); + + const Counter = () => { + const [count, inc] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('button')); + resolve(); + await findByText('count: -1'); +}); + +it('non suspense async write self atom with setTimeout (#389)', async () => { + const countAtom = atom(0, (get, set, _arg) => { + set(countAtom, get(countAtom) + 1); + setTimeout(() => set(countAtom, -1)); + }); + + const Counter = () => { + const [count, inc] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('count: 0'); + + // The use of fireEvent is required to reproduce the issue + fireEvent.click(getByText('button')); + await findByText('count: 1'); + await findByText('count: -1'); +}); + +it('should override promise as atom value (#430)', async () => { + const countAtom = atom(new Promise(() => {})); + const setCountAtom = atom(null, (_get, set, arg: number) => { + set(countAtom, Promise.resolve(arg)); + }); + + const Counter = () => { + const [count] = useAtom(countAtom); + return
count: {count * 1}
; + }; + + const Control = () => { + const [, setCount] = useAtom(setCountAtom); + return ; + }; + + const { getByText, findByText } = render( + + + + + + , + ); + + await findByText('loading'); + + await userEvent.click(getByText('button')); + await findByText('count: 1'); +}); + +it('combine two promise atom values (#442)', async () => { + const count1Atom = atom(new Promise(() => {})); + const count2Atom = atom(new Promise(() => {})); + const derivedAtom = atom( + async (get) => (await get(count1Atom)) + (await get(count2Atom)), + ); + const initAtom = atom(null, (_get, set) => { + setTimeout(() => set(count1Atom, Promise.resolve(1))); + setTimeout(() => set(count2Atom, Promise.resolve(2))); + }); + initAtom.onMount = (init) => { + init(); + }; + + const Counter = () => { + const [count] = useAtom(derivedAtom); + return
count: {count}
; + }; + + const Control = () => { + useAtom(initAtom); + return null; + }; + + const { findByText } = render( + + + + + + , + ); + + await findByText('loading'); + await findByText('count: 3'); +}); + +it('set two promise atoms at once', async () => { + const count1Atom = atom(new Promise(() => {})); + const count2Atom = atom(new Promise(() => {})); + const derivedAtom = atom( + async (get) => (await get(count1Atom)) + (await get(count2Atom)), + ); + const setCountsAtom = atom(null, (_get, set) => { + set(count1Atom, Promise.resolve(1)); + set(count2Atom, Promise.resolve(2)); + }); + + const Counter = () => { + const [count] = useAtom(derivedAtom); + return
count: {count}
; + }; + + const Control = () => { + const [, setCounts] = useAtom(setCountsAtom); + return ; + }; + + const { getByText, findByText } = render( + + + + + + , + ); + + await findByText('loading'); + + await userEvent.click(getByText('button')); + await findByText('count: 3'); +}); + +it('async write chain', async () => { + const countAtom = atom(0); + let resolve1 = () => {}; + const asyncWriteAtom = atom(null, async (_get, set, _arg) => { + await new Promise((r) => (resolve1 = r)); + set(countAtom, 2); + }); + let resolve2 = () => {}; + const controlAtom = atom(null, async (_get, set, _arg) => { + set(countAtom, 1); + await set(asyncWriteAtom, null); + await new Promise((r) => (resolve2 = r)); + set(countAtom, 3); + }); + + const Counter = () => { + const [count] = useAtom(countAtom); + return
count: {count}
; + }; + + const Control = () => { + const [, invoke] = useAtom(controlAtom); + return ; + }; + + const { getByText, findByText } = render( + + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('button')); + await findByText('count: 1'); + resolve1(); + await findByText('count: 2'); + resolve2(); + await findByText('count: 3'); +}); + +it('async atom double chain without setTimeout (#751)', async () => { + const enabledAtom = atom(false); + let resolve = () => {}; + const asyncAtom = atom(async (get) => { + const enabled = get(enabledAtom); + if (!enabled) { + return 'init'; + } + await new Promise((r) => (resolve = r)); + return 'ready'; + }); + const derivedAsyncAtom = atom(async (get) => get(asyncAtom)); + const anotherAsyncAtom = atom(async (get) => get(derivedAsyncAtom)); + + const AsyncComponent = () => { + const [text] = useAtom(anotherAsyncAtom); + return
async: {text}
; + }; + + const Parent = () => { + // Use useAtom to reproduce the issue + const [, setEnabled] = useAtom(enabledAtom); + return ( + <> + + + + + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('loading'); + await findByText('async: init'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve(); + await findByText('async: ready'); +}); + +it('async atom double chain with setTimeout', async () => { + const enabledAtom = atom(false); + const resolve: (() => void)[] = []; + const asyncAtom = atom(async (get) => { + const enabled = get(enabledAtom); + if (!enabled) { + return 'init'; + } + await new Promise((r) => resolve.push(r)); + return 'ready'; + }); + const derivedAsyncAtom = atom(async (get) => { + await new Promise((r) => resolve.push(r)); + return get(asyncAtom); + }); + const anotherAsyncAtom = atom(async (get) => { + await new Promise((r) => resolve.push(r)); + return get(derivedAsyncAtom); + }); + + const AsyncComponent = () => { + const [text] = useAtom(anotherAsyncAtom); + return
async: {text}
; + }; + + const Parent = () => { + // Use useAtom to reproduce the issue + const [, setEnabled] = useAtom(enabledAtom); + return ( + <> + + + + + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + act(() => resolve.splice(0).forEach((fn) => fn())); + await findByText('loading'); + + act(() => resolve.splice(0).forEach((fn) => fn())); + await act(() => new Promise((r) => setTimeout(r))); // wait for a tick + act(() => resolve.splice(0).forEach((fn) => fn())); + await findByText('async: init'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + act(() => resolve.splice(0).forEach((fn) => fn())); + await act(() => new Promise((r) => setTimeout(r))); // wait for a tick + act(() => resolve.splice(0).forEach((fn) => fn())); + await findByText('async: ready'); +}); + +it('update unmounted async atom with intermediate atom', async () => { + const enabledAtom = atom(true); + const countAtom = atom(1); + + const resolve: (() => void)[] = []; + const intermediateAtom = atom((get) => { + const count = get(countAtom); + const enabled = get(enabledAtom); + const tmpAtom = atom(async () => { + if (!enabled) { + return -1; + } + await new Promise((r) => resolve.push(r)); + return count * 2; + }); + return tmpAtom; + }); + const derivedAtom = atom((get) => { + const tmpAtom = get(intermediateAtom); + return get(tmpAtom); + }); + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom); + return
derived: {derived}
; + }; + + const Control = () => { + const [, setEnabled] = useAtom(enabledAtom); + const [, setCount] = useAtom(countAtom); + return ( + <> + + + + ); + }; + + const { getByText, findByText } = render( + + + + + + , + ); + + await findByText('loading'); + resolve.splice(0).forEach((fn) => fn()); + await findByText('derived: 2'); + + await userEvent.click(getByText('toggle enabled')); + await userEvent.click(getByText('increment count')); + await findByText('derived: -1'); + + await userEvent.click(getByText('toggle enabled')); + await findByText('loading'); + resolve.splice(0).forEach((fn) => fn()); + await findByText('derived: 4'); +}); + +it('multiple derived atoms with dependency chaining and async write (#813)', async () => { + const responseBaseAtom = atom<{ name: string }[] | null>(null); + + const response1 = [{ name: 'alpha' }, { name: 'beta' }]; + const responseAtom = atom( + (get) => get(responseBaseAtom), + (_get, set) => { + setTimeout(() => set(responseBaseAtom, response1)); + }, + ); + responseAtom.onMount = (init) => { + init(); + }; + + const mapAtom = atom((get) => get(responseAtom)); + const itemA = atom((get) => get(mapAtom)?.[0]); + const itemB = atom((get) => get(mapAtom)?.[1]); + const itemAName = atom((get) => get(itemA)?.name); + const itemBName = atom((get) => get(itemB)?.name); + + const App = () => { + const [aName] = useAtom(itemAName); + const [bName] = useAtom(itemBName); + return ( + <> +
aName: {aName}
+
bName: {bName}
+ + ); + }; + + const { getByText } = render( + + + , + ); + + await waitFor(() => { + getByText('aName: alpha'); + getByText('bName: beta'); + }); +}); diff --git a/__tests__/derive/baseTests/react/async2.test.tsx b/__tests__/derive/baseTests/react/async2.test.tsx new file mode 100644 index 0000000..78e65ed --- /dev/null +++ b/__tests__/derive/baseTests/react/async2.test.tsx @@ -0,0 +1,369 @@ +import { StrictMode, Suspense } from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom, useAtomValue, useSetAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; +import assert from 'minimalistic-assert'; + +describe('useAtom delay option test', () => { + it('suspend for Promise.resolve without delay option', async () => { + const countAtom = atom(0); + const asyncAtom = atom((get) => { + const count = get(countAtom); + if (count === 0) { + return 0; + } + return Promise.resolve(count); + }); + + const Component = () => { + const count = useAtomValue(asyncAtom); + return
count: {count}
; + }; + + const Controls = () => { + const setCount = useSetAtom(countAtom); + return ( + <> + + + ); + }; + + const { getByText, findByText } = render( + + + + + + , + ); + + await findByText('count: 0'); + + // The use of fireEvent is required to reproduce the issue + fireEvent.click(getByText('button')); + await findByText('loading'); + await findByText('count: 1'); + }); + + it('do not suspend for Promise.resolve with delay option', async () => { + const countAtom = atom(0); + const asyncAtom = atom((get) => { + const count = get(countAtom); + if (count === 0) { + return 0; + } + return Promise.resolve(count); + }); + + const Component = () => { + const count = useAtomValue(asyncAtom, { delay: 0 }); + return
count: {count}
; + }; + + const Controls = () => { + const setCount = useSetAtom(countAtom); + return ( + <> + + + ); + }; + + const { getByText, findByText } = render( + + + + , + ); + + await findByText('count: 0'); + + // The use of fireEvent is required to reproduce the issue + fireEvent.click(getByText('button')); + await findByText('count: 1'); + }); +}); + +describe('atom read function setSelf option test', () => { + it('do not suspend with promise resolving with setSelf', async () => { + const countAtom = atom(0); + let resolve = () => {}; + const asyncAtom = atom(async () => { + await new Promise((r) => (resolve = r)); + return 'hello'; + }); + const refreshAtom = atom(0); + const promiseCache = new WeakMap(); + const derivedAtom = atom( + (get, { setSelf }) => { + get(refreshAtom); + const count = get(countAtom); + const promise = get(asyncAtom); + if (promiseCache.has(promise)) { + return (promiseCache.get(promise) as string) + count; + } + promise.then((v) => { + promiseCache.set(promise, v); + setSelf(); + }); + return 'pending' + count; + }, + (_get, set) => { + set(refreshAtom, (c) => c + 1); + }, + ); + + const Component = () => { + const text = useAtomValue(derivedAtom); + return
text: {text}
; + }; + + const Controls = () => { + const setCount = useSetAtom(countAtom); + return ( + <> + + + ); + }; + + const { getByText, findByText } = render( + + + + , + ); + + await findByText('text: pending0'); + resolve(); + await findByText('text: hello0'); + + // The use of fireEvent is required to reproduce the issue + fireEvent.click(getByText('button')); + await findByText('text: hello1'); + }); +}); + +describe('timing issue with setSelf', () => { + it('resolves dependencies reliably after a delay (#2192)', async () => { + expect.assertions(1); + const countAtom = atom(0); + + let result: number | null = null; + const resolve: (() => void)[] = []; + const asyncAtom = atom(async (get) => { + const count = get(countAtom); + await new Promise((r) => resolve.push(r)); + return count; + }); + + const derivedAtom = atom( + async (get, { setSelf }) => { + get(countAtom); + await Promise.resolve(); + const resultCount = await get(asyncAtom); + result = resultCount; + if (resultCount === 2) setSelf(); // <-- necessary + }, + () => {}, + ); + + const derivedSyncAtom = atom((get) => { + get(derivedAtom); + }); + + const increment = (c: number) => c + 1; + function TestComponent() { + useAtom(derivedSyncAtom); + const [count, setCount] = useAtom(countAtom); + const onClick = () => { + setCount(increment); + setCount(increment); + }; + return ( + <> + count: {count} + + + ); + } + + const { getByText, findByText } = render( + + + , + ); + + await waitFor(() => assert(resolve.length === 1)); + resolve[0]!(); + + // The use of fireEvent is required to reproduce the issue + fireEvent.click(getByText('button')); + + await waitFor(() => assert(resolve.length === 3)); + resolve[1]!(); + resolve[2]!(); + + await waitFor(() => assert(result === 2)); + + // The use of fireEvent is required to reproduce the issue + fireEvent.click(getByText('button')); + + await waitFor(() => assert(resolve.length === 5)); + resolve[3]!(); + resolve[4]!(); + + await findByText('count: 4'); + expect(result).toBe(4); // 3 + }); +}); + +describe('infinite pending', () => { + it('odd counter', async () => { + const countAtom = atom(0); + const asyncAtom = atom((get) => { + const count = get(countAtom); + if (count % 2 === 0) { + const infinitePending = new Promise(() => {}); + return infinitePending; + } + return count; + }); + + const Component = () => { + const count = useAtomValue(asyncAtom); + return
count: {count}
; + }; + + const Controls = () => { + const setCount = useSetAtom(countAtom); + return ( + <> + + + ); + }; + + const { getByText, findByText } = render( + + + + + + , + ); + + await findByText('loading'); + + await userEvent.click(getByText('button')); + await findByText('count: 1'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + + await userEvent.click(getByText('button')); + await findByText('count: 3'); + }); +}); + +describe('write to async atom twice', () => { + it('no wait', async () => { + const asyncAtom = atom(Promise.resolve(2)); + const writer = atom(null, async (get, set) => { + set(asyncAtom, async (c) => (await c) + 1); + set(asyncAtom, async (c) => (await c) + 1); + return get(asyncAtom); + }); + + const Component = () => { + const count = useAtomValue(asyncAtom); + const write = useSetAtom(writer); + return ( + <> +
count: {count}
+ + + ); + }; + + const { findByText, getByText } = render( + + + + + , + ); + + await findByText('count: 2'); + await userEvent.click(getByText('button')); + await findByText('count: 4'); + }); + + it('wait Promise.resolve()', async () => { + const asyncAtom = atom(Promise.resolve(2)); + const writer = atom(null, async (get, set) => { + set(asyncAtom, async (c) => (await c) + 1); + await Promise.resolve(); + set(asyncAtom, async (c) => (await c) + 1); + return get(asyncAtom); + }); + + const Component = () => { + const count = useAtomValue(asyncAtom); + const write = useSetAtom(writer); + return ( + <> +
count: {count}
+ + + ); + }; + + const { findByText, getByText } = render( + + + + + , + ); + + await findByText('count: 2'); + await userEvent.click(getByText('button')); + await findByText('count: 4'); + }); + + it('wait setTimeout()', async () => { + const asyncAtom = atom(Promise.resolve(2)); + const writer = atom(null, async (get, set) => { + set(asyncAtom, async (c) => (await c) + 1); + await new Promise((r) => setTimeout(r)); + set(asyncAtom, async (c) => (await c) + 1); + return get(asyncAtom); + }); + + const Component = () => { + const count = useAtomValue(asyncAtom); + const write = useSetAtom(writer); + return ( + <> +
count: {count}
+ + + ); + }; + + const { findByText, getByText } = render( + + + + + , + ); + + await findByText('count: 2'); + await userEvent.click(getByText('button')); + await findByText('count: 4'); + }); +}); diff --git a/__tests__/derive/baseTests/react/basic.test.tsx b/__tests__/derive/baseTests/react/basic.test.tsx new file mode 100644 index 0000000..25dcd9c --- /dev/null +++ b/__tests__/derive/baseTests/react/basic.test.tsx @@ -0,0 +1,1003 @@ +import { + StrictMode, + Suspense, + version as reactVersion, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { unstable_batchedUpdates } from 'react-dom'; +import { useAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; +import type { PrimitiveAtom } from '../../../../jotai/vanilla'; + +const IS_REACT18 = /^18\./.test(reactVersion); + +const batchedUpdates = (fn: () => void) => { + if (IS_REACT18) { + fn(); + } else { + unstable_batchedUpdates(fn); + } +}; + +const useCommitCount = () => { + const commitCountRef = useRef(1); + useEffect(() => { + commitCountRef.current += 1; + }); + return commitCountRef.current; +}; + +it('uses a primitive atom', async () => { + const countAtom = atom(0); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('button')); + await findByText('count: 1'); +}); + +it('uses a read-only derived atom', async () => { + const countAtom = atom(0); + const doubledCountAtom = atom((get) => get(countAtom) * 2); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const [doubledCount] = useAtom(doubledCountAtom); + return ( + <> +
count: {count}
+
doubledCount: {doubledCount}
+ + + ); + }; + + const { getByText } = render( + + + , + ); + + await waitFor(() => { + getByText('count: 0'); + getByText('doubledCount: 0'); + }); + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 1'); + getByText('doubledCount: 2'); + }); +}); + +it('uses a read-write derived atom', async () => { + const countAtom = atom(0); + const doubledCountAtom = atom( + (get) => get(countAtom) * 2, + (get, set, update: number) => set(countAtom, get(countAtom) + update), + ); + + const Counter = () => { + const [count] = useAtom(countAtom); + const [doubledCount, increaseCount] = useAtom(doubledCountAtom); + return ( + <> +
count: {count}
+
doubledCount: {doubledCount}
+ + + ); + }; + + const { getByText } = render( + + + , + ); + + await waitFor(() => { + getByText('count: 0'); + getByText('doubledCount: 0'); + }); + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 2'); + getByText('doubledCount: 4'); + }); +}); + +it('uses a write-only derived atom', async () => { + const countAtom = atom(0); + const incrementCountAtom = atom(null, (get, set) => + set(countAtom, get(countAtom) + 1), + ); + + const Counter = () => { + const [count] = useAtom(countAtom); + return ( +
+ commits: {useCommitCount()}, count: {count} +
+ ); + }; + + const Control = () => { + const [, increment] = useAtom(incrementCountAtom); + return ( + <> +
button commits: {useCommitCount()}
+ + + ); + }; + + const { getByText } = render( + <> + + + , + ); + + await waitFor(() => { + getByText('commits: 1, count: 0'); + getByText('button commits: 1'); + }); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('commits: 2, count: 1'); + getByText('button commits: 1'); + }); +}); + +it('only re-renders if value has changed', async () => { + const count1Atom = atom(0); + const count2Atom = atom(0); + const productAtom = atom((get) => get(count1Atom) * get(count2Atom)); + + type Props = { countAtom: typeof count1Atom; name: string }; + const Counter = ({ countAtom, name }: Props) => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
+ commits: {useCommitCount()}, {name}: {count} +
+ + + ); + }; + + const Product = () => { + const [product] = useAtom(productAtom); + return ( + <> +
+ commits: {useCommitCount()}, product: {product} +
+ + ); + }; + + const { getByText } = render( + <> + + + + , + ); + + await waitFor(() => { + getByText('commits: 1, count1: 0'); + getByText('commits: 1, count2: 0'); + getByText('commits: 1, product: 0'); + }); + await userEvent.click(getByText('button-count1')); + await waitFor(() => { + getByText('commits: 2, count1: 1'); + getByText('commits: 1, count2: 0'); + getByText('commits: 1, product: 0'); + }); + await userEvent.click(getByText('button-count2')); + await waitFor(() => { + getByText('commits: 2, count1: 1'); + getByText('commits: 2, count2: 1'); + getByText('commits: 2, product: 1'); + }); +}); + +it('re-renders a time delayed derived atom with the same initial value (#947)', async () => { + const aAtom = atom(false); + aAtom.onMount = (set) => { + setTimeout(() => { + set(true); + }); + }; + + const bAtom = atom(1); + bAtom.onMount = (set) => { + set(2); + }; + + const cAtom = atom((get) => { + if (get(aAtom)) { + return get(bAtom); + } + return 1; + }); + + const App = () => { + const [value] = useAtom(cAtom); + return <>{value}; + }; + + const { findByText } = render( + + + , + ); + + await findByText('2'); +}); + +it('works with async get', async () => { + const countAtom = atom(0); + let resolve = () => {}; + const asyncCountAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)); + return get(countAtom); + }); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const [delayedCount] = useAtom(asyncCountAtom); + return ( + <> +
+ commits: {useCommitCount()}, count: {count}, delayedCount:{' '} + {delayedCount} +
+ + + ); + }; + + const { getByText, findByText } = render( + <> + + + + , + ); + + await findByText('loading'); + resolve(); + await findByText('commits: 1, count: 0, delayedCount: 0'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve(); + await findByText('commits: 2, count: 1, delayedCount: 1'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve(); + await findByText('commits: 3, count: 2, delayedCount: 2'); +}); + +it('works with async get without setTimeout', async () => { + const countAtom = atom(0); + const asyncCountAtom = atom(async (get) => { + return get(countAtom); + }); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const [delayedCount] = useAtom(asyncCountAtom); + return ( + <> +
+ count: {count}, delayedCount: {delayedCount} +
+ + + ); + }; + + const { getByText, findByText } = render( + + + + + , + ); + + await findByText('loading'); + await findByText('count: 0, delayedCount: 0'); + + await userEvent.click(getByText('button')); + await findByText('count: 1, delayedCount: 1'); + + await userEvent.click(getByText('button')); + await findByText('count: 2, delayedCount: 2'); +}); + +it('uses atoms with tree dependencies', async () => { + const topAtom = atom(0); + const leftAtom = atom((get) => get(topAtom)); + let resolve = () => {}; + const rightAtom = atom( + (get) => get(topAtom), + async (get, set, update: (prev: number) => number) => { + await new Promise((r) => (resolve = r)); + batchedUpdates(() => { + set(topAtom, update(get(topAtom))); + }); + }, + ); + + const Counter = () => { + const [count] = useAtom(leftAtom); + const [, setCount] = useAtom(rightAtom); + return ( + <> +
+ commits: {useCommitCount()}, count: {count} +
+ + + ); + }; + + const { getByText, findByText } = render( + <> + + , + ); + + await findByText('commits: 1, count: 0'); + + await userEvent.click(getByText('button')); + resolve(); + await findByText('commits: 2, count: 1'); + + await userEvent.click(getByText('button')); + resolve(); + await findByText('commits: 3, count: 2'); +}); + +it('runs update only once in StrictMode', async () => { + let updateCount = 0; + const countAtom = atom(0); + const derivedAtom = atom( + (get) => get(countAtom), + (_get, set, update: number) => { + updateCount += 1; + set(countAtom, update); + }, + ); + + const Counter = () => { + const [count, setCount] = useAtom(derivedAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('count: 0'); + expect(updateCount).toBe(0); + + await userEvent.click(getByText('button')); + await findByText('count: 1'); + expect(updateCount).toBe(1); +}); + +it('uses an async write-only atom', async () => { + const countAtom = atom(0); + let resolve = () => {}; + const asyncCountAtom = atom( + null, + async (get, set, update: (prev: number) => number) => { + await new Promise((r) => (resolve = r)); + set(countAtom, update(get(countAtom))); + }, + ); + + const Counter = () => { + const [count] = useAtom(countAtom); + const [, setCount] = useAtom(asyncCountAtom); + return ( + <> +
+ commits: {useCommitCount()}, count: {count} +
+ + + ); + }; + + const { getByText, findByText } = render( + <> + + , + ); + + await findByText('commits: 1, count: 0'); + + await userEvent.click(getByText('button')); + resolve(); + await findByText('commits: 2, count: 1'); +}); + +it('uses a writable atom without read function', async () => { + let resolve = () => {}; + const countAtom = atom(1, async (get, set, v: number) => { + await new Promise((r) => (resolve = r)); + set(countAtom, get(countAtom) + 10 * v); + }); + + const Counter = () => { + const [count, addCount10Times] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('count: 1'); + + await userEvent.click(getByText('button')); + resolve(); + await findByText('count: 11'); +}); + +it('can write an atom value on useEffect', async () => { + const countAtom = atom(0); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + useEffect(() => { + setCount((c) => c + 1); + }, [setCount]); + return
count: {count}
; + }; + + const { findByText } = render( + <> + + , + ); + + await findByText('count: 1'); +}); + +it('can write an atom value on useEffect in children', async () => { + const countAtom = atom(0); + + const Child = ({ + setCount, + }: { + setCount: (f: (c: number) => number) => void; + }) => { + useEffect(() => { + setCount((c) => c + 1); + }, [setCount]); + return null; + }; + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( +
+ count: {count} + + +
+ ); + }; + + const { findByText } = render( + <> + + , + ); + + await findByText('count: 2'); +}); + +it('only invoke read function on use atom', async () => { + const countAtom = atom(0); + let readCount = 0; + const doubledCountAtom = atom((get) => { + readCount += 1; + return get(countAtom) * 2; + }); + + expect(readCount).toBe(0); // do not invoke on atom() + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const [doubledCount] = useAtom(doubledCountAtom); + return ( + <> +
+ commits: {useCommitCount()}, count: {count}, readCount: {readCount}, + doubled: {doubledCount} +
+ + + ); + }; + + const { getByText, findByText } = render( + <> + + , + ); + + await findByText('commits: 1, count: 0, readCount: 1, doubled: 0'); + + await userEvent.click(getByText('button')); + await findByText('commits: 2, count: 1, readCount: 2, doubled: 2'); +}); + +it('uses a read-write derived atom with two primitive atoms', async () => { + const countAAtom = atom(0); + const countBAtom = atom(0); + const sumAtom = atom( + (get) => get(countAAtom) + get(countBAtom), + (_get, set) => { + set(countAAtom, 0); + set(countBAtom, 0); + }, + ); + const incBothAtom = atom(null, (get, set) => { + set(countAAtom, get(countAAtom) + 1); + set(countBAtom, get(countBAtom) + 1); + }); + + const Counter = () => { + const [countA, setCountA] = useAtom(countAAtom); + const [countB, setCountB] = useAtom(countBAtom); + const [sum, reset] = useAtom(sumAtom); + const [, incBoth] = useAtom(incBothAtom); + return ( + <> +
+ countA: {countA}, countB: {countB}, sum: {sum} +
+ + + + + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('countA: 0, countB: 0, sum: 0'); + + await userEvent.click(getByText('incA')); + await findByText('countA: 1, countB: 0, sum: 1'); + + await userEvent.click(getByText('incB')); + await findByText('countA: 1, countB: 1, sum: 2'); + + await userEvent.click(getByText('reset')); + await findByText('countA: 0, countB: 0, sum: 0'); + + await userEvent.click(getByText('incBoth')); + await findByText('countA: 1, countB: 1, sum: 2'); +}); + +it('updates a derived atom in useEffect with two primitive atoms', async () => { + const countAAtom = atom(0); + const countBAtom = atom(1); + const sumAtom = atom((get) => get(countAAtom) + get(countBAtom)); + + const Counter = () => { + const [countA, setCountA] = useAtom(countAAtom); + const [countB, setCountB] = useAtom(countBAtom); + const [sum] = useAtom(sumAtom); + useEffect(() => { + setCountA((c) => c + 1); + }, [setCountA, countB]); + return ( + <> +
+ countA: {countA}, countB: {countB}, sum: {sum} +
+ + + ); + }; + + const { getByText, findByText } = render( + <> + + , + ); + + await findByText('countA: 1, countB: 1, sum: 2'); + + await userEvent.click(getByText('button')); + await findByText('countA: 2, countB: 2, sum: 4'); +}); + +it('updates two atoms in child useEffect', async () => { + const countAAtom = atom(0); + const countBAtom = atom(10); + + const Child = () => { + const [countB, setCountB] = useAtom(countBAtom); + useEffect(() => { + setCountB((c) => c + 1); + }, [setCountB]); + return
countB: {countB}
; + }; + + const Counter = () => { + const [countA, setCountA] = useAtom(countAAtom); + useEffect(() => { + setCountA((c) => c + 1); + }, [setCountA]); + return ( + <> +
countA: {countA}
+ {countA > 0 && } + + ); + }; + + const { getByText } = render( + <> + + , + ); + + await waitFor(() => { + getByText('countA: 1'); + getByText('countB: 11'); + }); +}); + +it('set atom right after useEffect (#208)', async () => { + const countAtom = atom(0); + const effectFn = jest.fn(); + + const Child = () => { + const [count, setCount] = useAtom(countAtom); + const [, setState] = useState(null); + // rAF does not repro, so schedule update intentionally in render + if (count === 1) { + Promise.resolve().then(() => { + setCount(2); + }); + } + useEffect(() => { + effectFn(count); + setState(null); // this is important to repro (set something stable) + }, [count, setState]); + return
count: {count}
; + }; + + const Parent = () => { + const [, setCount] = useAtom(countAtom); + useEffect(() => { + setCount(1); + // requestAnimationFrame(() => setCount(2)) + }, [setCount]); + return ; + }; + + const { findByText } = render( + + + , + ); + + await findByText('count: 2'); + expect(effectFn).toHaveBeenLastCalledWith(2); +}); + +it('changes atom from parent (#273, #275)', async () => { + const atomA = atom({ id: 'a' }); + const atomB = atom({ id: 'b' }); + + const Item = ({ id }: { id: string }) => { + const a = useMemo(() => (id === 'a' ? atomA : atomB), [id]); + const [atomValue] = useAtom(a); + return ( +
+ commits: {useCommitCount()}, id: {atomValue.id} +
+ ); + }; + + const App = () => { + const [id, setId] = useState('a'); + return ( +
+ + + +
+ ); + }; + + const { getByText, findByText } = render( + <> + + , + ); + + await findByText('commits: 1, id: a'); + + await userEvent.click(getByText('atom a')); + await findByText('commits: 1, id: a'); + + await userEvent.click(getByText('atom b')); + await findByText('commits: 2, id: b'); + + await userEvent.click(getByText('atom a')); + await findByText('commits: 3, id: a'); +}); + +it('should be able to use a double derived atom twice and useEffect (#373)', async () => { + const countAtom = atom(0); + const doubleAtom = atom((get) => get(countAtom) * 2); + const fourfoldAtom = atom((get) => get(doubleAtom) * 2); + + const App = () => { + const [count, setCount] = useAtom(countAtom); + const [fourfold] = useAtom(fourfoldAtom); + const [fourfold2] = useAtom(fourfoldAtom); + + useEffect(() => { + setCount(count); + }, [count, setCount]); + + return ( +
+ count: {count},{fourfold},{fourfold2} + +
+ ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('count: 0,0,0'); + await userEvent.click(getByText('one up')); + await findByText('count: 1,4,4'); +}); + +it('write self atom (undocumented usage)', async () => { + const countAtom = atom(0, (get, set, _arg) => { + set(countAtom, get(countAtom) + 1); + }); + + const Counter = () => { + const [count, inc] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('button')); + await findByText('count: 1'); +}); + +it('async chain for multiple sync and async atoms (#443)', async () => { + const num1Atom = atom(async () => { + return 1; + }); + const num2Atom = atom(async () => { + return 2; + }); + + // "async" is required to reproduce the issue + const sumAtom = atom( + async (get) => (await get(num1Atom)) + (await get(num2Atom)), + ); + const countAtom = atom((get) => get(sumAtom)); + + const Counter = () => { + const [count] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + ); + }; + const { findByText } = render( + + + + + , + ); + + await findByText('loading'); + await findByText('count: 3'); +}); + +it('sync re-renders with useState re-renders (#827)', async () => { + const atom0 = atom('atom0'); + const atom1 = atom('atom1'); + const atom2 = atom('atom2'); + const atoms = [atom0, atom1, atom2]; + + const App = () => { + const [currentAtomIndex, setCurrentAtomIndex] = useState(0); + const rotateAtoms = () => { + setCurrentAtomIndex((prev) => (prev + 1) % atoms.length); + }; + const [atomValue] = useAtom( + atoms[currentAtomIndex] as (typeof atoms)[number], + ); + + return ( + <> + commits: {useCommitCount()} +

{atomValue}

+ + + ); + }; + const { findByText, getByText } = render( + <> + + , + ); + + await findByText('commits: 1'); + await userEvent.click(getByText('rotate')); + await findByText('commits: 2'); + await userEvent.click(getByText('rotate')); + await findByText('commits: 3'); +}); + +it('chained derive atom with onMount and useEffect (#897)', async () => { + const countAtom = atom(0); + countAtom.onMount = (set) => { + set(1); + }; + const derivedAtom = atom((get) => get(countAtom)); + const derivedObjectAtom = atom((get) => ({ + count: get(derivedAtom), + })); + + const Counter = () => { + const [, setCount] = useAtom(countAtom); + const [{ count }] = useAtom(derivedObjectAtom); + useEffect(() => { + setCount(1); + }, [setCount]); + return
count: {count}
; + }; + + const { findByText } = render( + + + , + ); + + await findByText('count: 1'); +}); + +it('onMount is not called when atom value is accessed from writeGetter in derived atom (#942)', async () => { + const onUnmount = jest.fn(); + const onMount = jest.fn(() => { + return onUnmount; + }); + + const aAtom = atom(false); + aAtom.onMount = onMount; + + const bAtom = atom(null, (get) => { + get(aAtom); + }); + + const App = () => { + const [, action] = useAtom(bAtom); + useEffect(() => action(), [action]); + return null; + }; + + render( + + + , + ); + + expect(onMount).not.toHaveBeenCalled(); + expect(onUnmount).not.toHaveBeenCalled(); +}); + +it('useAtom returns consistent value with input with changing atoms (#1235)', async () => { + const countAtom = atom(0); + const valueAtoms = [atom(0), atom(1)]; + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const [value] = useAtom(valueAtoms[count] as PrimitiveAtom); + if (count !== value) { + throw new Error('value mismatch'); + } + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('button')); + await findByText('count: 1'); +}); diff --git a/__tests__/derive/baseTests/react/dependency.test.tsx b/__tests__/derive/baseTests/react/dependency.test.tsx new file mode 100644 index 0000000..476010d --- /dev/null +++ b/__tests__/derive/baseTests/react/dependency.test.tsx @@ -0,0 +1,1041 @@ +import { StrictMode, Suspense, useEffect, useRef, useState } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom, useAtomValue, useSetAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; +import type { Atom, Getter } from '../../../../jotai/vanilla'; + +const useCommitCount = () => { + const commitCountRef = useRef(1); + useEffect(() => { + commitCountRef.current += 1; + }); + return commitCountRef.current; +}; + +it('works with 2 level dependencies', async () => { + const countAtom = atom(1); + const doubledAtom = atom((get) => get(countAtom) * 2); + const tripledAtom = atom((get) => get(doubledAtom) * 3); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const [doubledCount] = useAtom(doubledAtom); + const [tripledCount] = useAtom(tripledAtom); + return ( + <> +
+ commits: {useCommitCount()}, count: {count}, doubled: {doubledCount}, + tripled: {tripledCount} +
+ + + ); + }; + + const { getByText, findByText } = render( + <> + + , + ); + + await findByText('commits: 1, count: 1, doubled: 2, tripled: 6'); + + await userEvent.click(getByText('button')); + await findByText('commits: 2, count: 2, doubled: 4, tripled: 12'); +}); + +it('works a primitive atom and a dependent async atom', async () => { + const countAtom = atom(1); + let resolve = () => {}; + const doubledAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)); + return get(countAtom) * 2; + }); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const [doubledCount] = useAtom(doubledAtom); + return ( + <> +
+ count: {count}, doubled: {doubledCount} +
+ + + ); + }; + + const { getByText, findByText } = render( + + + + + , + ); + + await findByText('loading'); + resolve(); + await findByText('count: 1, doubled: 2'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve(); + await findByText('count: 2, doubled: 4'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve(); + await findByText('count: 3, doubled: 6'); +}); + +it('should keep an atom value even if unmounted', async () => { + const countAtom = atom(0); + const derivedFn = jest.fn((get: Getter) => get(countAtom)); + const derivedAtom = atom(derivedFn); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom); + return
derived: {derived}
; + }; + + const Parent = () => { + const [show, setShow] = useState(true); + return ( +
+ + {show ? ( + <> + + + + ) : ( +
hidden
+ )} +
+ ); + }; + + const { getByText } = render( + + + , + ); + + await waitFor(() => { + getByText('count: 0'); + getByText('derived: 0'); + }); + expect(derivedFn).toHaveReturnedTimes(1); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 1'); + getByText('derived: 1'); + }); + expect(derivedFn).toHaveReturnedTimes(2); + + await userEvent.click(getByText('toggle')); + await waitFor(() => { + getByText('hidden'); + }); + expect(derivedFn).toHaveReturnedTimes(2); + + await userEvent.click(getByText('toggle')); + await waitFor(() => { + getByText('count: 1'); + getByText('derived: 1'); + }); + expect(derivedFn).toHaveReturnedTimes(2); +}); + +it('should keep a dependent atom value even if unmounted', async () => { + const countAtom = atom(0); + const derivedFn = jest.fn((get: Getter) => get(countAtom)); + const derivedAtom = atom(derivedFn); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom); + return
derived: {derived}
; + }; + + const Parent = () => { + const [showDerived, setShowDerived] = useState(true); + return ( +
+ + {showDerived ? : } +
+ ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('derived: 0'); + expect(derivedFn).toHaveReturnedTimes(1); + + await userEvent.click(getByText('toggle')); + await findByText('count: 0'); + expect(derivedFn).toHaveReturnedTimes(1); + + await userEvent.click(getByText('button')); + await findByText('count: 1'); + expect(derivedFn).toHaveReturnedTimes(1); + + await userEvent.click(getByText('toggle')); + await findByText('derived: 1'); + expect(derivedFn).toHaveReturnedTimes(2); +}); + +it('should bail out updating if not changed', async () => { + const countAtom = atom(0); + const derivedFn = jest.fn((get: Getter) => get(countAtom)); + const derivedAtom = atom(derivedFn); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom); + return
derived: {derived}
; + }; + + const { getByText } = render( + + + + , + ); + + await waitFor(() => { + getByText('count: 0'); + getByText('derived: 0'); + }); + expect(derivedFn).toHaveReturnedTimes(1); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 0'); + getByText('derived: 0'); + }); + expect(derivedFn).toHaveReturnedTimes(1); +}); + +it('should bail out updating if not changed, 2 level', async () => { + const dataAtom = atom({ count: 1, obj: { anotherCount: 10 } }); + const getDataCountFn = jest.fn((get: Getter) => get(dataAtom).count); + const countAtom = atom(getDataCountFn); + const getDataObjFn = jest.fn((get: Getter) => get(dataAtom).obj); + const objAtom = atom(getDataObjFn); + const getAnotherCountFn = jest.fn((get: Getter) => get(objAtom).anotherCount); + const anotherCountAtom = atom(getAnotherCountFn); + + const Counter = () => { + const [count] = useAtom(countAtom); + const [, setData] = useAtom(dataAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const DerivedCounter = () => { + const [anotherCount] = useAtom(anotherCountAtom); + return
anotherCount: {anotherCount}
; + }; + + const { getByText } = render( + + + + , + ); + + await waitFor(() => { + getByText('count: 1'); + getByText('anotherCount: 10'); + }); + expect(getDataCountFn).toHaveReturnedTimes(1); + expect(getDataObjFn).toHaveReturnedTimes(1); + expect(getAnotherCountFn).toHaveReturnedTimes(1); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 2'); + getByText('anotherCount: 10'); + }); + expect(getDataCountFn).toHaveReturnedTimes(2); + expect(getDataObjFn).toHaveReturnedTimes(2); + expect(getAnotherCountFn).toHaveReturnedTimes(1); +}); + +it('derived atom to update base atom in callback', async () => { + const countAtom = atom(1); + const doubledAtom = atom( + (get) => get(countAtom) * 2, + (_get, _set, callback: () => void) => { + callback(); + }, + ); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const [doubledCount, dispatch] = useAtom(doubledAtom); + return ( + <> +
+ commits: {useCommitCount()}, count: {count}, doubled: {doubledCount} +
+ + + ); + }; + + const { getByText, findByText } = render( + <> + + , + ); + + await findByText('commits: 1, count: 1, doubled: 2'); + + await userEvent.click(getByText('button')); + await findByText('commits: 2, count: 2, doubled: 4'); +}); + +it('can read sync derived atom in write without initializing', async () => { + const countAtom = atom(1); + const doubledAtom = atom((get) => get(countAtom) * 2); + const addAtom = atom(null, (get, set, num: number) => { + set(countAtom, get(doubledAtom) / 2 + num); + }); + + const Counter = () => { + const [count] = useAtom(countAtom); + const [, add] = useAtom(addAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('count: 1'); + + await userEvent.click(getByText('button')); + await findByText('count: 2'); + + await userEvent.click(getByText('button')); + await findByText('count: 3'); +}); + +it('can remount atoms with dependency (#490)', async () => { + const countAtom = atom(0); + const derivedAtom = atom((get) => get(countAtom)); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom); + return
derived: {derived}
; + }; + + const Parent = () => { + const [showChildren, setShowChildren] = useState(true); + return ( +
+ + {showChildren ? ( + <> + + + + ) : ( +
hidden
+ )} +
+ ); + }; + + const { getByText } = render( + + + , + ); + + await waitFor(() => { + getByText('count: 0'); + getByText('derived: 0'); + }); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 1'); + getByText('derived: 1'); + }); + + await userEvent.click(getByText('toggle')); + await waitFor(() => { + getByText('hidden'); + }); + + await userEvent.click(getByText('toggle')); + await waitFor(() => { + getByText('count: 1'); + getByText('derived: 1'); + }); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 2'); + getByText('derived: 2'); + }); +}); + +it('can remount atoms with intermediate atom', async () => { + const countAtom = atom(1); + + const resultAtom = atom(0); + const intermediateAtom = atom((get) => { + const count = get(countAtom); + const initAtom = atom(null, (_get, set) => { + set(resultAtom, count * 2); + }); + initAtom.onMount = (init) => { + init(); + }; + return initAtom; + }); + const derivedAtom = atom((get) => { + const initAtom = get(intermediateAtom); + get(initAtom); + return get(resultAtom); + }); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom); + return
derived: {derived}
; + }; + + const Parent = () => { + const [showChildren, setShowChildren] = useState(true); + return ( +
+ + + {showChildren ? :
hidden
} +
+ ); + }; + + const { getByText } = render( + + + , + ); + + await waitFor(() => { + getByText('count: 1'); + getByText('derived: 2'); + }); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 2'); + getByText('derived: 4'); + }); + + await userEvent.click(getByText('toggle')); + await waitFor(() => { + getByText('count: 2'); + getByText('hidden'); + }); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 3'); + getByText('hidden'); + }); + + await userEvent.click(getByText('toggle')); + await waitFor(() => { + getByText('count: 3'); + getByText('derived: 6'); + }); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 4'); + getByText('derived: 8'); + }); +}); + +it('can update dependents with useEffect (#512)', async () => { + const enabledAtom = atom(false); + const countAtom = atom(1); + + const derivedAtom = atom((get) => { + const enabled = get(enabledAtom); + if (!enabled) { + return 0; + } + const count = get(countAtom); + return count * 2; + }); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom); + return
derived: {derived}
; + }; + + const Parent = () => { + const [, setEnabled] = useAtom(enabledAtom); + useEffect(() => { + setEnabled(true); + }, [setEnabled]); + return ( +
+ + +
+ ); + }; + + const { getByText } = render( + + + , + ); + + await waitFor(() => { + getByText('count: 1'); + getByText('derived: 2'); + }); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 2'); + getByText('derived: 4'); + }); +}); + +it('update unmounted atom with intermediate atom', async () => { + const enabledAtom = atom(true); + const countAtom = atom(1); + + const intermediateAtom = atom((get) => { + const count = get(countAtom); + const enabled = get(enabledAtom); + const tmpAtom = atom(enabled ? count * 2 : -1); + return tmpAtom; + }); + const derivedAtom = atom((get) => { + const tmpAtom = get(intermediateAtom); + return get(tmpAtom); + }); + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom); + return
derived: {derived}
; + }; + + const Control = () => { + const [, setEnabled] = useAtom(enabledAtom); + const [, setCount] = useAtom(countAtom); + return ( + <> + + + + ); + }; + + const { getByText, findByText } = render( + + + + , + ); + + await findByText('derived: 2'); + + await userEvent.click(getByText('toggle enabled')); + await userEvent.click(getByText('increment count')); + await findByText('derived: -1'); + + await userEvent.click(getByText('toggle enabled')); + await findByText('derived: 4'); +}); + +it('Should bail for derived sync chains (#877)', async () => { + let syncAtomCount = 0; + const textAtom = atom('hello'); + + const syncAtom = atom((get) => { + get(textAtom); + syncAtomCount++; + return 'My very long data'; + }); + + const derivedAtom = atom((get) => { + return get(syncAtom); + }); + + const Input = () => { + const [result] = useAtom(derivedAtom); + return
{result}
; + }; + + const ForceValue = () => { + const setText = useAtom(textAtom)[1]; + return ( +
+ +
+ ); + }; + + const { getByText, findByText } = render( + + + + , + ); + + await findByText('My very long data'); + expect(syncAtomCount).toBe(1); + + await userEvent.click(getByText(`set value to 'hello'`)); + + await findByText('My very long data'); + expect(syncAtomCount).toBe(1); +}); + +it('Should bail for derived async chains (#877)', async () => { + let syncAtomCount = 0; + const textAtom = atom('hello'); + + const asyncAtom = atom(async (get) => { + get(textAtom); + syncAtomCount++; + return 'My very long data'; + }); + + const derivedAtom = atom((get) => { + return get(asyncAtom); + }); + + const Input = () => { + const [result] = useAtom(derivedAtom); + return
{result}
; + }; + + const ForceValue = () => { + const setText = useAtom(textAtom)[1]; + return ( +
+ +
+ ); + }; + + const { getByText, findByText } = render( + + + + + + , + ); + + await findByText('My very long data'); + expect(syncAtomCount).toBe(1); + + await userEvent.click(getByText(`set value to 'hello'`)); + + await findByText('My very long data'); + expect(syncAtomCount).toBe(1); +}); + +it('update correctly with async updates (#1250)', async () => { + const countAtom = atom(0); + + const countIsGreaterThanOneAtom = atom((get) => get(countAtom) > 1); + + const alsoCountAtom = atom((get) => { + const count = get(countAtom); + get(countIsGreaterThanOneAtom); + return count; + }); + + const App = () => { + const setCount = useSetAtom(countAtom); + const alsoCount = useAtomValue(alsoCountAtom); + const countIsGreaterThanOne = useAtomValue(countIsGreaterThanOneAtom); + const incrementCountTwice = () => { + setTimeout(() => setCount((count) => count + 1)); + setTimeout(() => setCount((count) => count + 1)); + }; + return ( +
+ +
alsoCount: {alsoCount}
+
countIsGreaterThanOne: {countIsGreaterThanOne.toString()}
+
+ ); + }; + + const { getByText } = render( + + + , + ); + + await waitFor(() => { + getByText('alsoCount: 0'); + getByText('countIsGreaterThanOne: false'); + }); + + await userEvent.click(getByText('Increment Count Twice')); + await waitFor(() => { + getByText('alsoCount: 2'); + getByText('countIsGreaterThanOne: true'); + }); +}); + +describe('glitch free', () => { + it('basic', async () => { + const baseAtom = atom(0); + const derived1Atom = atom((get) => get(baseAtom)); + const derived2Atom = atom((get) => get(derived1Atom)); + const computeValue = jest.fn((get: Getter) => { + const v0 = get(baseAtom); + const v1 = get(derived1Atom); + const v2 = get(derived2Atom); + return `v0: ${v0}, v1: ${v1}, v2: ${v2}`; + }); + const derived3Atom = atom(computeValue); + + const App = () => { + const value = useAtomValue(derived3Atom); + return
value: {value}
; + }; + + const Control = () => { + const setCount = useSetAtom(baseAtom); + return ( + <> + + + ); + }; + + const { getByText, findByText } = render( + + + + , + ); + + await findByText('value: v0: 0, v1: 0, v2: 0'); + expect(computeValue).toHaveBeenCalledTimes(1); + + await userEvent.click(getByText('button')); + await findByText('value: v0: 1, v1: 1, v2: 1'); + expect(computeValue).toHaveBeenCalledTimes(2); + }); + + it('same value', async () => { + const baseAtom = atom(0); + const derived1Atom = atom((get) => get(baseAtom) * 0); + const derived2Atom = atom((get) => get(derived1Atom) * 0); + const computeValue = jest.fn((get: Getter) => { + const v0 = get(baseAtom); + const v1 = get(derived1Atom); + const v2 = get(derived2Atom); + return v0 + (v1 - v2); + }); + const derived3Atom = atom(computeValue); + + const App = () => { + const value = useAtomValue(derived3Atom); + return
value: {value}
; + }; + + const Control = () => { + const setCount = useSetAtom(baseAtom); + return ( + <> + + + ); + }; + + const { getByText, findByText } = render( + + + + , + ); + + await findByText('value: 0'); + expect(computeValue).toHaveBeenCalledTimes(1); + + await userEvent.click(getByText('button')); + await findByText('value: 1'); + expect(computeValue).toHaveBeenCalledTimes(2); + }); + + it('double chain', async () => { + const baseAtom = atom(0); + const derived1Atom = atom((get) => get(baseAtom)); + const derived2Atom = atom((get) => get(derived1Atom)); + const derived3Atom = atom((get) => get(derived2Atom)); + const computeValue = jest.fn((get: Getter) => { + const v0 = get(baseAtom); + const v1 = get(derived1Atom); + const v2 = get(derived2Atom); + const v3 = get(derived3Atom); + return v0 + (v1 - v2) + v3 * 0; + }); + const derived4Atom = atom(computeValue); + + const App = () => { + const value = useAtomValue(derived4Atom); + return
value: {value}
; + }; + + const Control = () => { + const setCount = useSetAtom(baseAtom); + return ( + <> + + + ); + }; + + const { getByText, findByText } = render( + + + + , + ); + + await findByText('value: 0'); + expect(computeValue).toHaveBeenCalledTimes(1); + + await userEvent.click(getByText('button')); + await findByText('value: 1'); + expect(computeValue).toHaveBeenCalledTimes(2); + }); +}); + +it('should not call read function for unmounted atoms in StrictMode (#2076)', async () => { + const countAtom = atom(1); + let firstDerivedFn: + | (((get: Getter) => number) & { mockClear: () => void }) + | undefined; + + const Component = () => { + const memoizedAtomRef = useRef | null>(null); + if (!memoizedAtomRef.current) { + const derivedFn = jest.fn((get: Getter) => get(countAtom)); + if (!firstDerivedFn) { + // eslint-disable-next-line react-compiler/react-compiler + firstDerivedFn = derivedFn; + } + memoizedAtomRef.current = atom(derivedFn); + } + useAtomValue(memoizedAtomRef.current); + return null; + }; + + const Main = () => { + const [show, setShow] = useState(true); + const setCount = useSetAtom(countAtom); + return ( + <> + + + {show && } + + ); + }; + + const { getByText } = render( + +
+ , + ); + + await userEvent.click(getByText('hide')); + expect(firstDerivedFn).toBeCalledTimes(1); + firstDerivedFn?.mockClear(); + + await userEvent.click(getByText('show')); + expect(firstDerivedFn).toBeCalledTimes(0); +}); + +it('works with unused hook (#2554)', async () => { + const isFooAtom = atom(false); + const isBarAtom = atom(false); + const isActive1Atom = atom((get) => { + return get(isFooAtom) && get(isBarAtom); + }); + const isActive2Atom = atom((get) => { + return get(isFooAtom) && get(isActive1Atom); + }); + const activateAction = atom(undefined, async (_get, set) => { + set(isFooAtom, true); + set(isBarAtom, true); + }); + + const App = () => { + const activate = useSetAtom(activateAction); + useAtomValue(isActive1Atom); + const isRunning = useAtomValue(isActive2Atom); + return ( +
+ + {isRunning ? 'running' : 'not running'} +
+ ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('not running'); + + await userEvent.click(getByText('Activate')); + await findByText('running'); +}); + +it('works with async dependencies (#2565)', async () => { + const countAtom = atom(0); + const countUpAction = atom(null, (_get, set) => { + set(countAtom, (prev) => prev + 1); + }); + const totalCountAtom = atom(async (get) => { + const base = await Promise.resolve(100); + const count = get(countAtom); + return base + count; + }); + + const Count = () => { + const count = useAtomValue(totalCountAtom); + return

count: {count}

; + }; + const App = () => { + const up = useSetAtom(countUpAction); + return ( +
+ + + + +
+ ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('loading'); + await findByText('count: 100'); + + await userEvent.click(getByText('Count Up')); + await findByText('count: 101'); + + await userEvent.click(getByText('Count Up')); + await findByText('count: 102'); +}); diff --git a/__tests__/derive/baseTests/react/error.test.tsx b/__tests__/derive/baseTests/react/error.test.tsx new file mode 100644 index 0000000..33d9575 --- /dev/null +++ b/__tests__/derive/baseTests/react/error.test.tsx @@ -0,0 +1,569 @@ +import { + Component, + StrictMode, + Suspense, + version as reactVersion, + useEffect, + useState, +} from 'react'; +import type { ReactNode } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; + +const consoleError = console.error; +const errorMessages: string[] = []; +beforeEach(() => { + errorMessages.splice(0); + console.error = jest.fn((err: string) => { + const match = /^(.*?)(\n|$)/.exec(err); + if (match?.[1]) { + errorMessages.push(match[1]); + } + }); +}); +afterEach(() => { + console.error = consoleError; +}); + +class ErrorBoundary extends Component< + { children: ReactNode }, + { hasError: false } | { hasError: true; error: Error } +> { + constructor(props: { message?: string; children: ReactNode }) { + super(props); + this.state = { hasError: false }; + } + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + render() { + return this.state.hasError ? ( +
+ Errored: {this.state.error.message} + +
+ ) : ( + this.props.children + ); + } +} + +it('can throw an initial error in read function', async () => { + const errorAtom = atom(() => { + throw new Error(); + }); + + const Counter = () => { + useAtom(errorAtom); + return ( + <> +
no error
+ + ); + }; + + const { findByText } = render( + + + + + , + ); + + await findByText('Errored:'); +}); + +it('can throw an error in read function', async () => { + const countAtom = atom(0); + const errorAtom = atom((get) => { + if (get(countAtom) === 0) { + return 0; + } + throw new Error(); + }); + + const Counter = () => { + const [, setCount] = useAtom(countAtom); + const [count] = useAtom(errorAtom); + return ( + <> +
count: {count}
+
no error
+ + + ); + }; + + const { getByText, findByText } = render( + + + + + , + ); + + await findByText('no error'); + + await userEvent.click(getByText('button')); + await findByText('Errored:'); +}); + +it('can throw an initial chained error in read function', async () => { + const errorAtom = atom(() => { + throw new Error(); + }); + const derivedAtom = atom((get) => get(errorAtom)); + + const Counter = () => { + useAtom(derivedAtom); + return ( + <> +
no error
+ + ); + }; + + const { findByText } = render( + + + + + , + ); + + await findByText('Errored:'); +}); + +it('can throw a chained error in read function', async () => { + const countAtom = atom(0); + const errorAtom = atom((get) => { + if (get(countAtom) === 0) { + return 0; + } + throw new Error(); + }); + const derivedAtom = atom((get) => get(errorAtom)); + + const Counter = () => { + const [, setCount] = useAtom(countAtom); + const [count] = useAtom(derivedAtom); + return ( + <> +
count: {count}
+
no error
+ + + ); + }; + + const { getByText, findByText } = render( + + + + + , + ); + + await findByText('no error'); + + await userEvent.click(getByText('button')); + await findByText('Errored:'); +}); + +it('can throw an initial error in async read function', async () => { + const errorAtom = atom(async () => { + throw new Error(); + }); + + const Counter = () => { + useAtom(errorAtom); + return ( + <> +
no error
+ + ); + }; + + const { findByText } = render( + + + + + + + , + ); + + await findByText('Errored:'); +}); + +it('can throw an error in async read function', async () => { + const countAtom = atom(0); + const errorAtom = atom(async (get) => { + if (get(countAtom) === 0) { + return 0; + } + throw new Error(); + }); + + const Counter = () => { + const [, setCount] = useAtom(countAtom); + const [count] = useAtom(errorAtom); + return ( + <> +
count: {count}
+
no error
+ + + ); + }; + + const { getByText, findByText } = render( + + + + + + + , + ); + + await findByText('no error'); + + await userEvent.click(getByText('button')); + await findByText('Errored:'); +}); + +it('can throw an error in write function', async () => { + const countAtom = atom(0); + const errorAtom = atom( + (get) => get(countAtom), + () => { + throw new Error('error_in_write_function'); + }, + ); + + const Counter = () => { + const [count, dispatch] = useAtom(errorAtom); + const onClick = () => { + try { + dispatch(); + } catch (e) { + console.error(e); + } + }; + return ( + <> +
count: {count}
+
no error
+ + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('no error'); + expect(errorMessages).not.toContain('Error: error_in_write_function'); + + await userEvent.click(getByText('button')); + expect(errorMessages).toContain('Error: error_in_write_function'); +}); + +it('can throw an error in async write function', async () => { + const countAtom = atom(0); + const errorAtom = atom( + (get) => get(countAtom), + async () => { + throw new Error('error_in_async_write_function'); + }, + ); + + const Counter = () => { + const [count, dispatch] = useAtom(errorAtom); + const onClick = async () => { + try { + await dispatch(); + } catch (e) { + console.error(e); + } + }; + return ( + <> +
count: {count}
+
no error
+ + + ); + }; + + const { getByText, findByText } = render( + + + + + , + ); + + await findByText('no error'); + expect(errorMessages).not.toContain('Error: error_in_async_write_function'); + + await userEvent.click(getByText('button')); + await waitFor(() => { + expect(errorMessages).toContain('Error: error_in_async_write_function'); + }); +}); + +it('can throw a chained error in write function', async () => { + const countAtom = atom(0); + const errorAtom = atom( + (get) => get(countAtom), + () => { + throw new Error('chained_err_in_write'); + }, + ); + const chainedAtom = atom( + (get) => get(errorAtom), + (_get, set) => { + set(errorAtom); + }, + ); + + const Counter = () => { + const [count, dispatch] = useAtom(chainedAtom); + const onClick = () => { + try { + dispatch(); + } catch (e) { + console.error(e); + } + }; + return ( + <> +
count: {count}
+
no error
+ + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('no error'); + expect(errorMessages).not.toContain('Error: chained_err_in_write'); + + await userEvent.click(getByText('button')); + expect(errorMessages).toContain('Error: chained_err_in_write'); +}); + +it('throws an error while updating in effect', async () => { + const countAtom = atom(0); + + const Counter = () => { + const [, setCount] = useAtom(countAtom); + useEffect(() => { + try { + setCount(() => { + throw new Error('err_updating_in_effect'); + }); + } catch (e) { + console.error(e); + } + }, [setCount]); + return ( + <> +
no error
+ + ); + }; + + const { findByText } = render( + + + + + , + ); + + await findByText('no error'); + expect(errorMessages).toContain('Error: err_updating_in_effect'); +}); + +describe('throws an error while updating in effect cleanup', () => { + const countAtom = atom(0); + + let doubleSetCount = false; + + const Counter = () => { + const [, setCount] = useAtom(countAtom); + useEffect(() => { + return () => { + if (doubleSetCount) { + setCount((x) => x + 1); + } + setCount(() => { + throw new Error('err_in_effect_cleanup'); + }); + }; + }, [setCount]); + return ( + <> +
no error
+ + ); + }; + + const Main = () => { + const [hide, setHide] = useState(false); + return ( + <> + + {!hide && } + + ); + }; + + it('[DEV-ONLY] single setCount', async () => { + const { getByText, findByText } = render( + <> + +
+ + , + ); + + await findByText('no error'); + expect(errorMessages).not.toContain( + 'Error: Uncaught [Error: err_in_effect_cleanup]', + ); + + await userEvent.click(getByText('close')); + if (reactVersion.startsWith('17.')) { + expect(errorMessages).toContain( + 'Error: Uncaught [Error: err_in_effect_cleanup]', + ); + } else { + await findByText('Errored: err_in_effect_cleanup'); + } + }); + + it('[DEV-ONLY] dobule setCount', async () => { + doubleSetCount = true; + + const { getByText, findByText } = render( + <> + +
+ + , + ); + + await findByText('no error'); + expect(errorMessages).not.toContain( + 'Error: Uncaught [Error: err_in_effect_cleanup]', + ); + + await userEvent.click(getByText('close')); + if (reactVersion.startsWith('17.')) { + expect(errorMessages).toContain( + 'Error: Uncaught [Error: err_in_effect_cleanup]', + ); + } else { + await findByText('Errored: err_in_effect_cleanup'); + } + }); +}); + +describe('error recovery', () => { + const createCounter = () => { + const counterAtom = atom(0); + + const Counter = () => { + const [count, setCount] = useAtom(counterAtom); + return ; + }; + + return { Counter, counterAtom }; + }; + + it('recovers from sync errors', async () => { + const { counterAtom, Counter } = createCounter(); + + const syncAtom = atom((get) => { + const value = get(counterAtom); + + if (value === 0) { + throw new Error('An error occurred'); + } + + return value; + }); + + const Display = () => { + return
Value: {useAtom(syncAtom)[0]}
; + }; + + const { getByText, findByText } = render( + + + + + + , + ); + + await findByText('Errored: An error occurred'); + + await userEvent.click(getByText('increment')); + await userEvent.click(getByText('retry')); + await findByText('Value: 1'); + }); + + it('recovers from async errors', async () => { + const { counterAtom, Counter } = createCounter(); + let resolve = () => {}; + const asyncAtom = atom(async (get) => { + const value = get(counterAtom); + await new Promise((r) => (resolve = r)); + if (value === 0) { + throw new Error('An error occurred'); + } + return value; + }); + + const Display = () => { + return
Value: {useAtom(asyncAtom)[0]}
; + }; + + const { getByText, findByText } = render( + + + + + + + + , + ); + + resolve(); + await findByText('Errored: An error occurred'); + + await userEvent.click(getByText('increment')); + await userEvent.click(getByText('retry')); + resolve(); + await findByText('Value: 1'); + }); +}); diff --git a/__tests__/derive/baseTests/react/items.test.tsx b/__tests__/derive/baseTests/react/items.test.tsx new file mode 100644 index 0000000..a43968f --- /dev/null +++ b/__tests__/derive/baseTests/react/items.test.tsx @@ -0,0 +1,203 @@ +import { StrictMode } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; +import type { PrimitiveAtom } from '../../../../jotai/vanilla'; + +it('remove an item, then add another', async () => { + type Item = { + text: string; + checked: boolean; + }; + let itemIndex = 0; + const itemsAtom = atom[]>([]); + + const ListItem = ({ + itemAtom, + remove, + }: { + itemAtom: PrimitiveAtom; + remove: () => void; + }) => { + const [item, setItem] = useAtom(itemAtom); + const toggle = () => + setItem((prev) => ({ ...prev, checked: !prev.checked })); + return ( + <> +
+ {item.text} checked: {item.checked ? 'yes' : 'no'} +
+ + + + ); + }; + + const List = () => { + const [items, setItems] = useAtom(itemsAtom); + const addItem = () => { + setItems((prev) => [ + ...prev, + atom({ text: `item${++itemIndex}`, checked: false }), + ]); + }; + const removeItem = (itemAtom: PrimitiveAtom) => { + setItems((prev) => prev.filter((x) => x !== itemAtom)); + }; + return ( +
    + {items.map((itemAtom) => ( + removeItem(itemAtom)} + /> + ))} +
  • + +
  • +
+ ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await userEvent.click(getByText('Add')); + await findByText('item1 checked: no'); + + await userEvent.click(getByText('Add')); + await waitFor(() => { + getByText('item1 checked: no'); + getByText('item2 checked: no'); + }); + + await userEvent.click(getByText('Check item2')); + await waitFor(() => { + getByText('item1 checked: no'); + getByText('item2 checked: yes'); + }); + + await userEvent.click(getByText('Remove item1')); + await findByText('item2 checked: yes'); + + await userEvent.click(getByText('Add')); + await waitFor(() => { + getByText('item2 checked: yes'); + getByText('item3 checked: no'); + }); +}); + +it('add an item with filtered list', async () => { + type Item = { + text: string; + checked: boolean; + }; + type ItemAtoms = PrimitiveAtom[]; + type Update = (prev: ItemAtoms) => ItemAtoms; + + let itemIndex = 0; + const itemAtomsAtom = atom([]); + const setItemsAtom = atom(null, (_get, set, update: Update) => + set(itemAtomsAtom, update), + ); + const filterAtom = atom<'all' | 'checked' | 'not-checked'>('all'); + const filteredAtom = atom((get) => { + const filter = get(filterAtom); + const items = get(itemAtomsAtom); + if (filter === 'all') { + return items; + } + if (filter === 'checked') { + return items.filter((atom) => get(atom).checked); + } + return items.filter((atom) => !get(atom).checked); + }); + + const ListItem = ({ + itemAtom, + remove, + }: { + itemAtom: PrimitiveAtom; + remove: () => void; + }) => { + const [item, setItem] = useAtom(itemAtom); + const toggle = () => + setItem((prev) => ({ ...prev, checked: !prev.checked })); + return ( + <> +
+ {item.text} checked: {item.checked ? 'yes' : 'no'} +
+ + + + ); + }; + + const Filter = () => { + const [filter, setFilter] = useAtom(filterAtom); + return ( + <> +
{filter}
+ + + + + ); + }; + + const FilteredList = ({ + removeItem, + }: { + removeItem: (itemAtom: PrimitiveAtom) => void; + }) => { + const [items] = useAtom(filteredAtom); + return ( +
    + {items.map((itemAtom) => ( + removeItem(itemAtom)} + /> + ))} +
+ ); + }; + + const List = () => { + const [, setItems] = useAtom(setItemsAtom); + const addItem = () => { + setItems((prev) => [ + ...prev, + atom({ text: `item${++itemIndex}`, checked: false }), + ]); + }; + const removeItem = (itemAtom: PrimitiveAtom) => { + setItems((prev) => prev.filter((x) => x !== itemAtom)); + }; + return ( + <> + + + + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await userEvent.click(getByText('Checked')); + await userEvent.click(getByText('Add')); + await userEvent.click(getByText('All')); + await findByText('item1 checked: no'); +}); diff --git a/__tests__/derive/baseTests/react/onmount.test.tsx b/__tests__/derive/baseTests/react/onmount.test.tsx new file mode 100644 index 0000000..22fa74d --- /dev/null +++ b/__tests__/derive/baseTests/react/onmount.test.tsx @@ -0,0 +1,520 @@ +import { StrictMode, Suspense, useState } from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; + +it('one atom, one effect', async () => { + const countAtom = atom(1); + const onMountFn = jest.fn(() => {}); + countAtom.onMount = onMountFn; + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + <> + + , + ); + + await findByText('count: 1'); + expect(onMountFn).toHaveBeenCalledTimes(1); + + await userEvent.click(getByText('button')); + await findByText('count: 2'); + expect(onMountFn).toHaveBeenCalledTimes(1); +}); + +it('two atoms, one each', async () => { + const countAtom = atom(1); + const countAtom2 = atom(1); + const onMountFn = jest.fn(() => {}); + const onMountFn2 = jest.fn(() => {}); + countAtom.onMount = onMountFn; + countAtom2.onMount = onMountFn2; + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const [count2, setCount2] = useAtom(countAtom2); + return ( + <> +
count: {count}
+
count2: {count2}
+ + + ); + }; + + const { getByText } = render( + <> + + , + ); + + await waitFor(() => { + getByText('count: 1'); + getByText('count2: 1'); + }); + expect(onMountFn).toHaveBeenCalledTimes(1); + expect(onMountFn2).toHaveBeenCalledTimes(1); + + await userEvent.click(getByText('button')); + await waitFor(() => { + getByText('count: 2'); + getByText('count2: 2'); + }); + + expect(onMountFn).toHaveBeenCalledTimes(1); + expect(onMountFn2).toHaveBeenCalledTimes(1); +}); + +it('one derived atom, one onMount', async () => { + const countAtom = atom(1); + const countAtom2 = atom((get) => get(countAtom)); + const onMountFn = jest.fn(() => {}); + countAtom.onMount = onMountFn; + + const Counter = () => { + const [count] = useAtom(countAtom2); + return ( + <> +
count: {count}
+ + ); + }; + + const { findByText } = render( + <> + + , + ); + + await findByText('count: 1'); + expect(onMountFn).toHaveBeenCalledTimes(1); +}); + +it('mount/unmount test', async () => { + const countAtom = atom(1); + + const onUnMountFn = jest.fn(); + const onMountFn = jest.fn(() => onUnMountFn); + countAtom.onMount = onMountFn; + + const Counter = () => { + const [count] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + ); + }; + + const Display = () => { + const [display, setDisplay] = useState(true); + return ( + <> + {display ? : null} + + + ); + }; + + const { getByText } = render( + <> + + , + ); + + expect(onMountFn).toHaveBeenCalledTimes(1); + expect(onUnMountFn).toHaveBeenCalledTimes(0); + + await userEvent.click(getByText('button')); + await waitFor(() => { + expect(onMountFn).toHaveBeenCalledTimes(1); + expect(onUnMountFn).toHaveBeenCalledTimes(1); + }); +}); + +it('one derived atom, one onMount for the derived one, and one for the regular atom + onUnMount', async () => { + const countAtom = atom(1); + const derivedAtom = atom( + (get) => get(countAtom), + (_get, set, update: number) => { + set(countAtom, update); + set(derivedAtom, update); + }, + ); + const onUnMountFn = jest.fn(); + const onMountFn = jest.fn(() => onUnMountFn); + countAtom.onMount = onMountFn; + const derivedOnUnMountFn = jest.fn(); + const derivedOnMountFn = jest.fn(() => derivedOnUnMountFn); + derivedAtom.onMount = derivedOnMountFn; + + const Counter = () => { + const [count] = useAtom(derivedAtom); + return ( + <> +
count: {count}
+ + ); + }; + + const Display = () => { + const [display, setDisplay] = useState(true); + return ( + <> + {display ? : null} + + + ); + }; + + const { getByText } = render( + <> + + , + ); + expect(derivedOnMountFn).toHaveBeenCalledTimes(1); + expect(derivedOnUnMountFn).toHaveBeenCalledTimes(0); + expect(onMountFn).toHaveBeenCalledTimes(1); + expect(onUnMountFn).toHaveBeenCalledTimes(0); + + await userEvent.click(getByText('button')); + await waitFor(() => { + expect(derivedOnMountFn).toHaveBeenCalledTimes(1); + expect(derivedOnUnMountFn).toHaveBeenCalledTimes(1); + expect(onMountFn).toHaveBeenCalledTimes(1); + expect(onUnMountFn).toHaveBeenCalledTimes(1); + }); +}); + +it('mount/unMount order', async () => { + const committed: number[] = [0, 0]; + const countAtom = atom(1); + const derivedAtom = atom( + (get) => get(countAtom), + (_get, set, update: number) => { + set(countAtom, update); + set(derivedAtom, update); + }, + ); + const onUnMountFn = jest.fn(() => { + committed[0] = 0; + }); + const onMountFn = jest.fn(() => { + committed[0] = 1; + return onUnMountFn; + }); + countAtom.onMount = onMountFn; + const derivedOnUnMountFn = jest.fn(() => { + committed[1] = 0; + }); + const derivedOnMountFn = jest.fn(() => { + committed[1] = 1; + return derivedOnUnMountFn; + }); + derivedAtom.onMount = derivedOnMountFn; + + const Counter2 = () => { + const [count] = useAtom(derivedAtom); + return ( + <> +
count: {count}
+ + ); + }; + const Counter = () => { + const [count] = useAtom(countAtom); + const [display, setDisplay] = useState(false); + return ( + <> +
count: {count}
+ + {display ? : null} + + ); + }; + + const Display = () => { + const [display, setDisplay] = useState(false); + return ( + <> + {display ? : null} + + + ); + }; + + const { getByText } = render( + + + , + ); + expect(committed).toEqual([0, 0]); + + await userEvent.click(getByText('button')); + await waitFor(() => { + expect(committed).toEqual([1, 0]); + }); + + await userEvent.click(getByText('derived atom')); + await waitFor(() => { + expect(committed).toEqual([1, 1]); + }); + + await userEvent.click(getByText('derived atom')); + await waitFor(() => { + expect(committed).toEqual([1, 0]); + }); + + await userEvent.click(getByText('button')); + await waitFor(() => { + expect(committed).toEqual([0, 0]); + }); +}); + +it('mount/unmount test with async atom', async () => { + let resolve = () => {}; + const countAtom = atom( + async () => { + await new Promise((r) => (resolve = r)); + return 0; + }, + () => {}, + ); + + const onUnMountFn = jest.fn(); + const onMountFn = jest.fn(() => onUnMountFn); + countAtom.onMount = onMountFn; + + const Counter = () => { + const [count] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + ); + }; + + const Display = () => { + const [display, setDisplay] = useState(true); + return ( + <> + {display ? : null} + + + ); + }; + + const { getByText, findByText } = render( + <> + + + + , + ); + + await findByText('loading'); + resolve(); + await findByText('count: 0'); + expect(onMountFn).toHaveBeenCalledTimes(1); + expect(onUnMountFn).toHaveBeenCalledTimes(0); + + await userEvent.click(getByText('button')); + expect(onMountFn).toHaveBeenCalledTimes(1); + expect(onUnMountFn).toHaveBeenCalledTimes(1); +}); + +it('subscription usage test', async () => { + const store = { + count: 10, + listeners: new Set<() => void>(), + inc: () => { + store.count += 1; + store.listeners.forEach((listener) => listener()); + }, + }; + + const countAtom = atom(1); + countAtom.onMount = (setCount) => { + const callback = () => { + setCount(store.count); + }; + store.listeners.add(callback); + callback(); + return () => store.listeners.delete(callback); + }; + + const Counter = () => { + const [count] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + ); + }; + + const Display = () => { + const [display, setDisplay] = useState(true); + return ( + <> + {display ? : 'N/A'} + + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('count: 10'); + + act(() => { + store.inc(); + }); + await findByText('count: 11'); + + await userEvent.click(getByText('button')); + await findByText('N/A'); + + await userEvent.click(getByText('button')); + await findByText('count: 11'); + + await userEvent.click(getByText('button')); + await findByText('N/A'); + + act(() => { + store.inc(); + }); + await findByText('N/A'); + + await userEvent.click(getByText('button')); + await findByText('count: 12'); +}); + +it('subscription in base atom test', async () => { + const store = { + count: 10, + listeners: new Set<() => void>(), + add: (n: number) => { + store.count += n; + store.listeners.forEach((listener) => listener()); + }, + }; + + const countAtom = atom(1); + countAtom.onMount = (setCount) => { + const callback = () => { + setCount(store.count); + }; + store.listeners.add(callback); + callback(); + return () => store.listeners.delete(callback); + }; + const derivedAtom = atom( + (get) => get(countAtom), + (_get, _set, n: number) => { + store.add(n); + }, + ); + + const Counter = () => { + const [count, add] = useAtom(derivedAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('count: 10'); + + await userEvent.click(getByText('button')); + await findByText('count: 11'); + + await userEvent.click(getByText('button')); + await findByText('count: 12'); +}); + +it('create atom with onMount in async get', async () => { + const store = { + count: 10, + listeners: new Set<() => void>(), + add: (n: number) => { + store.count += n; + store.listeners.forEach((listener) => listener()); + }, + }; + + const holderAtom = atom(async () => { + const countAtom = atom(1); + countAtom.onMount = (setCount) => { + const callback = () => { + setCount(store.count); + }; + store.listeners.add(callback); + callback(); + return () => store.listeners.delete(callback); + }; + return countAtom; + }); + const derivedAtom = atom( + async (get) => get(await get(holderAtom)), + (_get, _set, n: number) => { + store.add(n); + }, + ); + + const Counter = () => { + const [count, add] = useAtom(derivedAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + + + + + , + ); + + await findByText('count: 1'); + await findByText('count: 10'); + + await userEvent.click(getByText('button')); + await findByText('count: 11'); + + await userEvent.click(getByText('button')); + await findByText('count: 12'); +}); diff --git a/__tests__/derive/baseTests/react/optimization.test.tsx b/__tests__/derive/baseTests/react/optimization.test.tsx new file mode 100644 index 0000000..2eefdac --- /dev/null +++ b/__tests__/derive/baseTests/react/optimization.test.tsx @@ -0,0 +1,273 @@ +import { useEffect } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; + +it('only relevant render function called (#156)', async () => { + const count1Atom = atom(0); + const count2Atom = atom(0); + + let renderCount1 = 0; + let renderCount2 = 0; + + const Counter1 = () => { + const [count, setCount] = useAtom(count1Atom); + ++renderCount1; + return ( + <> +
count1: {count}
+ + + ); + }; + + const Counter2 = () => { + const [count, setCount] = useAtom(count2Atom); + ++renderCount2; + return ( + <> +
count2: {count}
+ + + ); + }; + + const { getByText } = render( + <> + + + , + ); + + await waitFor(() => { + getByText('count1: 0'); + getByText('count2: 0'); + }); + const renderCount1AfterMount = renderCount1; + const renderCount2AfterMount = renderCount2; + + await userEvent.click(getByText('button1')); + await waitFor(() => { + getByText('count1: 1'); + getByText('count2: 0'); + }); + expect(renderCount1).toBe(renderCount1AfterMount + 1); + expect(renderCount2).toBe(renderCount2AfterMount + 0); + + await userEvent.click(getByText('button2')); + await waitFor(() => { + getByText('count1: 1'); + getByText('count2: 1'); + }); + expect(renderCount1).toBe(renderCount1AfterMount + 1); + expect(renderCount2).toBe(renderCount2AfterMount + 1); +}); + +it('only render once using atoms with write-only atom', async () => { + const count1Atom = atom(0); + const count2Atom = atom(0); + const incrementAtom = atom(null, (_get, set, _arg) => { + set(count1Atom, (c) => c + 1); + set(count2Atom, (c) => c + 1); + }); + + let renderCount = 0; + + const Counter = () => { + const [count1] = useAtom(count1Atom); + const [count2] = useAtom(count2Atom); + ++renderCount; + return ( +
+ count1: {count1}, count2: {count2} +
+ ); + }; + + const Control = () => { + const [, increment] = useAtom(incrementAtom); + return ; + }; + + const { getByText, findByText } = render( + <> + + + , + ); + + await findByText('count1: 0, count2: 0'); + const renderCountAfterMount = renderCount; + + await userEvent.click(getByText('button')); + await findByText('count1: 1, count2: 1'); + expect(renderCount).toBe(renderCountAfterMount + 1); + + await userEvent.click(getByText('button')); + await findByText('count1: 2, count2: 2'); + expect(renderCount).toBe(renderCountAfterMount + 2); +}); + +it('useless re-renders with static atoms (#355)', async () => { + // check out https://codesandbox.io/s/m82r5 to see the expected re-renders + const countAtom = atom(0); + const unrelatedAtom = atom(0); + + let renderCount = 0; + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + useAtom(unrelatedAtom); + ++renderCount; + + return ( + <> +
count: {count}
+ + + ); + }; + + const { getByText, findByText } = render( + <> + + , + ); + + await findByText('count: 0'); + const renderCountAfterMount = renderCount; + + await userEvent.click(getByText('button')); + await findByText('count: 1'); + expect(renderCount).toBe(renderCountAfterMount + 1); + + await userEvent.click(getByText('button')); + await findByText('count: 2'); + expect(renderCount).toBe(renderCountAfterMount + 2); +}); + +it('does not re-render if value is the same (#1158)', async () => { + const countAtom = atom(0); + + let renderCount = 0; + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + ++renderCount; + return ( + <> +
count: {count}
+ + + + ); + }; + + const { getByText, findByText } = render( + <> + + , + ); + + await findByText('count: 0'); + const renderCountAfterMount = renderCount; + + await userEvent.click(getByText('noop')); + await findByText('count: 0'); + expect(renderCount).toBe(renderCountAfterMount + 0); + + await userEvent.click(getByText('inc')); + await findByText('count: 1'); + expect(renderCount).toBe(renderCountAfterMount + 1); + + await userEvent.click(getByText('noop')); + await findByText('count: 1'); + expect(renderCount).toBe(renderCountAfterMount + 1); + + await userEvent.click(getByText('inc')); + await findByText('count: 2'); + expect(renderCount).toBe(renderCountAfterMount + 2); +}); + +it('no extra rerenders after commit with derived atoms (#1213)', async () => { + const baseAtom = atom({ count1: 0, count2: 0 }); + const count1Atom = atom((get) => get(baseAtom).count1); + const count2Atom = atom((get) => get(baseAtom).count2); + + let renderCount1 = 0; + let renderCount1AfterCommit = 0; + + const Counter1 = () => { + const [count1] = useAtom(count1Atom); + ++renderCount1; + useEffect(() => { + renderCount1AfterCommit = renderCount1; + }); + return
count1: {count1}
; + }; + + let renderCount2 = 0; + let renderCount2AfterCommit = 0; + + const Counter2 = () => { + const [count2] = useAtom(count2Atom); + ++renderCount2; + useEffect(() => { + renderCount2AfterCommit = renderCount2; + }); + return
count2: {count2}
; + }; + + const Control = () => { + const [, setValue] = useAtom(baseAtom); + const inc1 = () => { + setValue((prev) => ({ ...prev, count1: prev.count1 + 1 })); + }; + const inc2 = () => { + setValue((prev) => ({ ...prev, count2: prev.count2 + 1 })); + }; + return ( +
+ + +
+ ); + }; + + const { getByText } = render( + <> + + + + , + ); + + await waitFor(() => { + getByText('count1: 0'); + getByText('count2: 0'); + }); + expect(renderCount1 > 0).toBeTruthy(); + expect(renderCount2 > 0).toBeTruthy(); + + await userEvent.click(getByText('inc1')); + await waitFor(() => { + getByText('count1: 1'); + getByText('count2: 0'); + }); + expect(renderCount1).toBe(renderCount1AfterCommit); + + await userEvent.click(getByText('inc2')); + await waitFor(() => { + getByText('count1: 1'); + getByText('count2: 1'); + }); + expect(renderCount2).toBe(renderCount2AfterCommit); + + await userEvent.click(getByText('inc1')); + await waitFor(() => { + getByText('count1: 2'); + getByText('count2: 1'); + }); + expect(renderCount1).toBe(renderCount1AfterCommit); +}); diff --git a/__tests__/derive/baseTests/react/provider.test.tsx b/__tests__/derive/baseTests/react/provider.test.tsx new file mode 100644 index 0000000..1903738 --- /dev/null +++ b/__tests__/derive/baseTests/react/provider.test.tsx @@ -0,0 +1,80 @@ +import { StrictMode } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { Provider, useAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; +import { createStore } from '../../derivedStore'; + +it('uses initial values from provider', async () => { + const countAtom = atom(1); + const petAtom = atom('cat'); + + const Display = () => { + const [count] = useAtom(countAtom); + const [pet] = useAtom(petAtom); + + return ( + <> +

count: {count}

+

pet: {pet}

+ + ); + }; + + const store = createStore(); + store.set(countAtom, 0); + store.set(petAtom, 'dog'); + + const { getByText } = render( + + + + + , + ); + + await waitFor(() => { + getByText('count: 0'); + getByText('pet: dog'); + }); +}); + +it('only uses initial value from provider for specific atom', async () => { + const countAtom = atom(1); + const petAtom = atom('cat'); + + const Display = () => { + const [count] = useAtom(countAtom); + const [pet] = useAtom(petAtom); + + return ( + <> +

count: {count}

+

pet: {pet}

+ + ); + }; + + const store = createStore(); + store.set(petAtom, 'dog'); + + const { getByText } = render( + + + + + , + ); + + await waitFor(() => { + getByText('count: 1'); + getByText('pet: dog'); + }); +}); + +it('renders correctly without children', () => { + render( + + + , + ); +}); diff --git a/__tests__/derive/baseTests/react/useAtomValue.test.tsx b/__tests__/derive/baseTests/react/useAtomValue.test.tsx new file mode 100644 index 0000000..c1f3324 --- /dev/null +++ b/__tests__/derive/baseTests/react/useAtomValue.test.tsx @@ -0,0 +1,30 @@ +import { StrictMode } from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtomValue, useSetAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; + +it('useAtomValue basic test', async () => { + const countAtom = atom(0); + + const Counter = () => { + const count = useAtomValue(countAtom); + const setCount = useSetAtom(countAtom); + + return ( + <> +
count: {count}
+ + + ); + }; + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 0'); + await userEvent.click(getByText('dispatch')); + await findByText('count: 1'); +}); diff --git a/__tests__/derive/baseTests/react/useSetAtom.test.tsx b/__tests__/derive/baseTests/react/useSetAtom.test.tsx new file mode 100644 index 0000000..b036aad --- /dev/null +++ b/__tests__/derive/baseTests/react/useSetAtom.test.tsx @@ -0,0 +1,119 @@ +import { StrictMode, useEffect, useRef } from 'react'; +import type { PropsWithChildren } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtomValue, useSetAtom } from '../../../../jotai/react'; +import { atom } from '../../../../jotai/vanilla'; + +const useCommitCount = () => { + const commitCountRef = useRef(1); + useEffect(() => { + commitCountRef.current += 1; + }); + return commitCountRef.current; +}; + +it('useSetAtom does not trigger rerender in component', async () => { + const countAtom = atom(0); + + const Displayer = () => { + const count = useAtomValue(countAtom); + const commits = useCommitCount(); + return ( +
+ count: {count}, commits: {commits} +
+ ); + }; + + const Updater = () => { + const setCount = useSetAtom(countAtom); + const commits = useCommitCount(); + return ( + <> + +
updater commits: {commits}
+ + ); + }; + + const Parent = () => { + return ( + <> + + + + ); + }; + + const { getByText } = render( + <> + + , + ); + + await waitFor(() => { + getByText('count: 0, commits: 1'); + getByText('updater commits: 1'); + }); + await userEvent.click(getByText('increment')); + await waitFor(() => { + getByText('count: 1, commits: 2'); + getByText('updater commits: 1'); + }); + await userEvent.click(getByText('increment')); + await waitFor(() => { + getByText('count: 2, commits: 3'); + getByText('updater commits: 1'); + }); + await userEvent.click(getByText('increment')); + await waitFor(() => { + getByText('count: 3, commits: 4'); + getByText('updater commits: 1'); + }); +}); + +it('useSetAtom with write without an argument', async () => { + const countAtom = atom(0); + const incrementCountAtom = atom(null, (get, set) => + set(countAtom, get(countAtom) + 1), + ); + + const Button = ({ cb, children }: PropsWithChildren<{ cb: () => void }>) => ( + + ); + + const Displayer = () => { + const count = useAtomValue(countAtom); + return
count: {count}
; + }; + + const Updater = () => { + const setCount = useSetAtom(incrementCountAtom); + return ; + }; + + const Parent = () => { + return ( + <> + + + + ); + }; + const { getByText } = render( + + + , + ); + + await waitFor(() => { + getByText('count: 0'); + }); + await userEvent.click(getByText('increment')); + await waitFor(() => { + getByText('count: 1'); + }); +}); diff --git a/__tests__/derive/baseTests/react/utils/types.test.tsx b/__tests__/derive/baseTests/react/utils/types.test.tsx new file mode 100644 index 0000000..cc3ccfb --- /dev/null +++ b/__tests__/derive/baseTests/react/utils/types.test.tsx @@ -0,0 +1,34 @@ +import { useHydrateAtoms } from '../../../../../jotai/react/utils'; +import { atom } from '../../../../../jotai/vanilla'; + +it('useHydrateAtoms should not allow invalid atom types when array is passed', () => { + function Component() { + const countAtom = atom(0); + const activeAtom = atom(true); + // Adding @ts-ignore for typescript 3.8 support + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // @ts-expect-error TS2769 + useHydrateAtoms([ + [countAtom, 'foo'], + [activeAtom, 0], + ]); + // Adding @ts-ignore for typescript 3.8 support + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // @ts-expect-error TS2769 + useHydrateAtoms([ + [countAtom, 1], + [activeAtom, 0], + ]); + // Adding @ts-ignore for typescript 3.8 support + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // @ts-expect-error TS2769 + useHydrateAtoms([ + [countAtom, true], + [activeAtom, false], + ]); + } + expect(Component).toBeDefined(); +}); diff --git a/__tests__/derive/baseTests/react/utils/useAtomCallback.test.tsx b/__tests__/derive/baseTests/react/utils/useAtomCallback.test.tsx new file mode 100644 index 0000000..aec9449 --- /dev/null +++ b/__tests__/derive/baseTests/react/utils/useAtomCallback.test.tsx @@ -0,0 +1,175 @@ +import { StrictMode, useCallback, useEffect, useState } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom } from '../../../../../jotai/react'; +import { useAtomCallback } from '../../../../../jotai/react/utils'; +import { atom } from '../../../../../jotai/vanilla'; + +it('useAtomCallback with get', async () => { + const countAtom = atom(0); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
atom count: {count}
+ + + ); + }; + + const Monitor = () => { + const [count, setCount] = useState(0); + const readCount = useAtomCallback( + useCallback((get) => { + const currentCount = get(countAtom); + setCount(currentCount); + return currentCount; + }, []), + ); + useEffect(() => { + const timer = setInterval(() => { + readCount(); + }, 10); + return () => { + clearInterval(timer); + }; + }, [readCount]); + return ( + <> +
state count: {count}
+ + ); + }; + + const { findByText, getByText } = render( + + + + , + ); + + await findByText('atom count: 0'); + await userEvent.click(getByText('dispatch')); + await waitFor(() => { + getByText('atom count: 1'); + getByText('state count: 1'); + }); +}); + +it('useAtomCallback with set and update', async () => { + const countAtom = atom(0); + const changeableAtom = atom(0); + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const Monitor = () => { + const [changeableCount] = useAtom(changeableAtom); + const changeCount = useAtomCallback( + useCallback((get, set) => { + const currentCount = get(countAtom); + set(changeableAtom, currentCount); + return currentCount; + }, []), + ); + useEffect(() => { + const timer = setInterval(() => { + changeCount(); + }, 10); + return () => { + clearInterval(timer); + }; + }, [changeCount]); + return ( + <> +
changeable count: {changeableCount}
+ + ); + }; + + const { findByText, getByText } = render( + + + + , + ); + + await findByText('count: 0'); + await userEvent.click(getByText('dispatch')); + await waitFor(() => { + getByText('count: 1'); + getByText('changeable count: 1'); + }); +}); + +it('useAtomCallback with set and update and arg', async () => { + const countAtom = atom(0); + + const App = () => { + const [count] = useAtom(countAtom); + const setCount = useAtomCallback( + useCallback((_get, set, arg: number) => { + set(countAtom, arg); + return arg; + }, []), + ); + + return ( +
+

count: {count}

+ +
+ ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 0'); + await userEvent.click(getByText('dispatch')); + await waitFor(() => { + getByText('count: 42'); + }); +}); + +it('useAtomCallback with sync atom (#1100)', async () => { + const countAtom = atom(0); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + const readCount = useAtomCallback(useCallback((get) => get(countAtom), [])); + useEffect(() => { + const promiseOrValue = readCount(); + if (typeof promiseOrValue !== 'number') { + throw new Error('should return number'); + } + }, [readCount]); + return ( + <> +
atom count: {count}
+ + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('atom count: 0'); + + await userEvent.click(getByText('dispatch')); + await findByText('atom count: 1'); +}); diff --git a/__tests__/derive/baseTests/react/utils/useHydrateAtoms.test.tsx b/__tests__/derive/baseTests/react/utils/useHydrateAtoms.test.tsx new file mode 100644 index 0000000..43fd584 --- /dev/null +++ b/__tests__/derive/baseTests/react/utils/useHydrateAtoms.test.tsx @@ -0,0 +1,319 @@ +import { StrictMode, useEffect, useRef } from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom, useAtomValue } from '../../../../../jotai/react'; +import { useHydrateAtoms } from '../../../../../jotai/react/utils'; +import { atom } from '../../../../../jotai/vanilla'; + +it('useHydrateAtoms should only hydrate on first render', async () => { + const countAtom = atom(0); + const statusAtom = atom('fulfilled'); + + const Counter = ({ + initialCount, + initialStatus, + }: { + initialCount: number; + initialStatus: string; + }) => { + useHydrateAtoms([ + [countAtom, initialCount], + [statusAtom, initialStatus], + ]); + const [countValue, setCount] = useAtom(countAtom); + const [statusValue, setStatus] = useAtom(statusAtom); + + return ( + <> +
count: {countValue}
+ +
status: {statusValue}
+ + + ); + }; + const { findByText, getByText, rerender } = render( + + + , + ); + + await findByText('count: 42'); + await findByText('status: rejected'); + await userEvent.click(getByText('dispatch')); + await userEvent.click(getByText('update')); + await findByText('count: 43'); + await findByText('status: fulfilled'); + + rerender( + + + , + ); + await findByText('count: 43'); + await findByText('status: fulfilled'); +}); + +it('useHydrateAtoms should only hydrate on first render using a Map', async () => { + const countAtom = atom(0); + const activeAtom = atom(true); + + const Counter = ({ + initialActive = false, + initialCount, + }: { + initialActive?: boolean; + initialCount: number; + }) => { + useHydrateAtoms( + new Map< + typeof activeAtom | typeof countAtom, + typeof initialActive | typeof initialCount + >([ + [activeAtom, initialActive], + [countAtom, initialCount], + ]), + ); + const activeValue = useAtomValue(activeAtom); + const [countValue, setCount] = useAtom(countAtom); + + return ( + <> +
is active: {activeValue ? 'yes' : 'no'}
+
count: {countValue}
+ + + ); + }; + const { findByText, getByText, rerender } = render( + + + , + ); + + await findByText('count: 42'); + await findByText('is active: no'); + await userEvent.click(getByText('dispatch')); + await findByText('count: 43'); + + rerender( + + + , + ); + await findByText('count: 43'); + await findByText('is active: no'); +}); + +it('useHydrateAtoms should not trigger unnecessary re-renders', async () => { + const countAtom = atom(0); + + const Counter = ({ initialCount }: { initialCount: number }) => { + useHydrateAtoms([[countAtom, initialCount]]); + const [countValue, setCount] = useAtom(countAtom); + const commitCount = useRef(1); + useEffect(() => { + ++commitCount.current; + }); + return ( + <> +
commits: {commitCount.current}
+
count: {countValue}
+ + + ); + }; + + const { findByText, getByText } = render( + <> + + , + ); + + await findByText('count: 42'); + await findByText('commits: 1'); + await userEvent.click(getByText('dispatch')); + await findByText('count: 43'); + await findByText('commits: 2'); +}); + +it('useHydrateAtoms should work with derived atoms', async () => { + const countAtom = atom(0); + const doubleAtom = atom((get) => get(countAtom) * 2); + + const Counter = ({ initialCount }: { initialCount: number }) => { + useHydrateAtoms([[countAtom, initialCount]]); + const [countValue, setCount] = useAtom(countAtom); + const [doubleCount] = useAtom(doubleAtom); + return ( + <> +
count: {countValue}
+
doubleCount: {doubleCount}
+ + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 42'); + await findByText('doubleCount: 84'); + await userEvent.click(getByText('dispatch')); + await findByText('count: 43'); + await findByText('doubleCount: 86'); +}); + +it('useHydrateAtoms can only restore an atom once', async () => { + const countAtom = atom(0); + + const Counter = ({ initialCount }: { initialCount: number }) => { + useHydrateAtoms([[countAtom, initialCount]]); + const [countValue, setCount] = useAtom(countAtom); + + return ( + <> +
count: {countValue}
+ + + ); + }; + const Counter2 = ({ count }: { count: number }) => { + useHydrateAtoms([[countAtom, count]]); + const [countValue, setCount] = useAtom(countAtom); + + return ( + <> +
count: {countValue}
+ + + ); + }; + const { findByText, getByText, rerender } = render( + + + , + ); + + await findByText('count: 42'); + await userEvent.click(getByText('dispatch')); + await findByText('count: 43'); + + rerender( + + + , + ); + + await findByText('count: 43'); + await userEvent.click(getByText('dispatch')); + await findByText('count: 44'); +}); + +it('useHydrateAtoms should respect onMount', async () => { + const countAtom = atom(0); + const onMountFn = jest.fn(() => {}); + countAtom.onMount = onMountFn; + + const Counter = ({ initialCount }: { initialCount: number }) => { + useHydrateAtoms([[countAtom, initialCount]]); + const [countValue] = useAtom(countAtom); + + return
count: {countValue}
; + }; + const { findByText } = render( + <> + + , + ); + + await findByText('count: 42'); + expect(onMountFn).toHaveBeenCalledTimes(1); +}); + +it('passing dangerouslyForceHydrate to useHydrateAtoms will re-hydrated atoms', async () => { + const countAtom = atom(0); + const statusAtom = atom('fulfilled'); + + const Counter = ({ + initialCount, + initialStatus, + dangerouslyForceHydrate = false, + }: { + initialCount: number; + initialStatus: string; + dangerouslyForceHydrate?: boolean; + }) => { + useHydrateAtoms( + [ + [countAtom, initialCount], + [statusAtom, initialStatus], + ], + { + dangerouslyForceHydrate, + }, + ); + const [countValue, setCount] = useAtom(countAtom); + const [statusValue, setStatus] = useAtom(statusAtom); + + return ( + <> +
count: {countValue}
+ +
status: {statusValue}
+ + + ); + }; + const { findByText, getByText, rerender } = render( + + + , + ); + + await findByText('count: 42'); + await findByText('status: rejected'); + await userEvent.click(getByText('dispatch')); + await userEvent.click(getByText('update')); + await findByText('count: 43'); + await findByText('status: fulfilled'); + + rerender( + + + , + ); + await findByText('count: 43'); + await findByText('status: fulfilled'); + + rerender( + + + , + ); + await findByText('count: 11'); + await findByText('status: rejected'); +}); diff --git a/__tests__/derive/baseTests/react/utils/useReducerAtom.test.tsx b/__tests__/derive/baseTests/react/utils/useReducerAtom.test.tsx new file mode 100644 index 0000000..886c722 --- /dev/null +++ b/__tests__/derive/baseTests/react/utils/useReducerAtom.test.tsx @@ -0,0 +1,123 @@ +import { StrictMode } from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useReducerAtom } from '../../../../../jotai/react/utils'; +import { atom } from '../../../../../jotai/vanilla'; + +let savedConsoleWarn: any; +beforeEach(() => { + savedConsoleWarn = console.warn; + console.warn = jest.fn(); +}); +afterEach(() => { + console.warn = savedConsoleWarn; +}); + +it('useReducerAtom with no action argument', async () => { + const countAtom = atom(0); + const reducer = (state: number) => state + 2; + + const Parent = () => { + const [count, dispatch] = useReducerAtom(countAtom, reducer); + return ( + <> +
count: {count}
+ + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('dispatch')); + await findByText('count: 2'); + + await userEvent.click(getByText('dispatch')); + await findByText('count: 4'); +}); + +it('useReducerAtom with optional action argument', async () => { + const countAtom = atom(0); + const reducer = (state: number, action?: 'INCREASE' | 'DECREASE') => { + switch (action) { + case 'INCREASE': + return state + 1; + case 'DECREASE': + return state - 1; + case undefined: + return state; + } + }; + + const Parent = () => { + const [count, dispatch] = useReducerAtom(countAtom, reducer); + return ( + <> +
count: {count}
+ + + + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('dispatch INCREASE')); + await findByText('count: 1'); + + await userEvent.click(getByText('dispatch empty')); + await findByText('count: 1'); + + await userEvent.click(getByText('dispatch DECREASE')); + await findByText('count: 0'); +}); + +it('useReducerAtom with non-optional action argument', async () => { + const countAtom = atom(0); + const reducer = (state: number, action: 'INCREASE' | 'DECREASE') => { + switch (action) { + case 'INCREASE': + return state + 1; + case 'DECREASE': + return state - 1; + } + }; + + const Parent = () => { + const [count, dispatch] = useReducerAtom(countAtom, reducer); + return ( + <> +
count: {count}
+ + + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('dispatch INCREASE')); + await findByText('count: 1'); + + await userEvent.click(getByText('dispatch DECREASE')); + await findByText('count: 0'); +}); diff --git a/__tests__/derive/baseTests/react/utils/useResetAtom.test.tsx b/__tests__/derive/baseTests/react/utils/useResetAtom.test.tsx new file mode 100644 index 0000000..e58315d --- /dev/null +++ b/__tests__/derive/baseTests/react/utils/useResetAtom.test.tsx @@ -0,0 +1,178 @@ +import { StrictMode } from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom } from '../../../../../jotai/react'; +import { useResetAtom } from '../../../../../jotai/react/utils'; +import { atom } from '../../../../../jotai/vanilla'; +import { + RESET, + atomWithReducer, + atomWithReset, +} from '../../../../../jotai/vanilla/utils'; + +it('atomWithReset resets to its first value', async () => { + const countAtom = atomWithReset(0); + + const Parent = () => { + const [count, setValue] = useAtom(countAtom); + const resetAtom = useResetAtom(countAtom); + return ( + <> +
count: {count}
+ + + + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('increment')); + await findByText('count: 1'); + await userEvent.click(getByText('increment')); + await findByText('count: 2'); + await userEvent.click(getByText('increment')); + await findByText('count: 3'); + + await userEvent.click(getByText('reset')); + await findByText('count: 0'); + + await userEvent.click(getByText('set to 10')); + await findByText('count: 10'); + + await userEvent.click(getByText('increment')); + await findByText('count: 11'); + await userEvent.click(getByText('increment')); + await findByText('count: 12'); + await userEvent.click(getByText('increment')); + await findByText('count: 13'); +}); + +it('atomWithReset reset based on previous value', async () => { + const countAtom = atomWithReset(0); + + const Parent = () => { + const [count, setValue] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('increment till 3, then reset')); + await findByText('count: 1'); + await userEvent.click(getByText('increment till 3, then reset')); + await findByText('count: 2'); + await userEvent.click(getByText('increment till 3, then reset')); + await findByText('count: 3'); + + await userEvent.click(getByText('increment till 3, then reset')); + await findByText('count: 0'); +}); + +it('atomWithReset through read-write atom', async () => { + const primitiveAtom = atomWithReset(0); + const countAtom = atom( + (get) => get(primitiveAtom), + (_get, set, newValue: number | typeof RESET) => + set(primitiveAtom, newValue as never), + ); + + const Parent = () => { + const [count, setValue] = useAtom(countAtom); + const resetAtom = useResetAtom(countAtom); + return ( + <> +
count: {count}
+ + + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('set to 10')); + await findByText('count: 10'); + + await userEvent.click(getByText('reset')); + await findByText('count: 0'); +}); + +it('useResetAtom with custom atom', async () => { + const reducer = (state: number, action: 'INCREASE' | typeof RESET) => { + switch (action) { + case 'INCREASE': + return state + 1; + case RESET: + return 0; + default: + throw new Error('unknown action'); + } + }; + + const countAtom = atomWithReducer(0, reducer); + + const Parent = () => { + const [count, dispatch] = useAtom(countAtom); + const resetAtom = useResetAtom(countAtom); + return ( + <> +
count: {count}
+ + + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('increment')); + await findByText('count: 1'); + await userEvent.click(getByText('increment')); + await findByText('count: 2'); + await userEvent.click(getByText('increment')); + await findByText('count: 3'); + + await userEvent.click(getByText('reset')); + await findByText('count: 0'); +}); diff --git a/__tests__/derive/baseTests/react/vanilla-utils/atomFamily.test.tsx b/__tests__/derive/baseTests/react/vanilla-utils/atomFamily.test.tsx new file mode 100644 index 0000000..d5d202b --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/atomFamily.test.tsx @@ -0,0 +1,286 @@ +import { StrictMode, Suspense, useState } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom, useSetAtom } from '../../../../../jotai/react'; +import { atom } from '../../../../../jotai/vanilla'; +import type { + SetStateAction, + WritableAtom, +} from '../../../../../jotai/vanilla'; +import { atomFamily } from '../../../../../jotai/vanilla/utils'; + +it('new atomFamily impl', async () => { + const myFamily = atomFamily((param: string) => atom(param)); + + const Displayer = ({ index }: { index: string }) => { + const [count] = useAtom(myFamily(index)); + return
count: {count}
; + }; + const { findByText } = render( + + + , + ); + + await findByText('count: a'); +}); + +it('primitive atomFamily returns same reference for same parameters', async () => { + const myFamily = atomFamily((num: number) => atom({ num })); + expect(myFamily(0)).toEqual(myFamily(0)); + expect(myFamily(0)).not.toEqual(myFamily(1)); + expect(myFamily(1)).not.toEqual(myFamily(0)); +}); + +it('read-only derived atomFamily returns same reference for same parameters', async () => { + const arrayAtom = atom([0]); + const myFamily = atomFamily((num: number) => + atom((get) => get(arrayAtom)[num] as number), + ); + expect(myFamily(0)).toEqual(myFamily(0)); + expect(myFamily(0)).not.toEqual(myFamily(1)); + expect(myFamily(1)).not.toEqual(myFamily(0)); +}); + +it('removed atom creates a new reference', async () => { + const bigAtom = atom([0]); + const myFamily = atomFamily((num: number) => + atom((get) => get(bigAtom)[num] as number), + ); + + const savedReference = myFamily(0); + + expect(savedReference).toEqual(myFamily(0)); + + myFamily.remove(0); + + const newReference = myFamily(0); + + expect(savedReference).not.toEqual(newReference); + + myFamily.remove(1337); + + expect(myFamily(0)).toEqual(newReference); +}); + +it('primitive atomFamily initialized with props', async () => { + const myFamily = atomFamily((param: number) => atom(param)); + + const Displayer = ({ index }: { index: number }) => { + const [count, setCount] = useAtom(myFamily(index)); + return ( +
+ count: {count} + +
+ ); + }; + + const Parent = () => { + const [index, setIndex] = useState(1); + + return ( +
+ + +
+ ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 1'); + + await userEvent.click(getByText('button')); + await findByText('count: 11'); + + await userEvent.click(getByText('increment')); + await findByText('count: 2'); + + await userEvent.click(getByText('button')); + await findByText('count: 12'); +}); + +it('derived atomFamily functionality as usual', async () => { + const arrayAtom = atom([0, 0, 0]); + + const myFamily = atomFamily((param: number) => + atom( + (get) => get(arrayAtom)[param] as number, + (_, set, update) => { + set(arrayAtom, (oldArray) => { + if (typeof oldArray[param] === 'undefined') return oldArray; + + const newValue = + typeof update === 'function' + ? update(oldArray[param] as number) + : update; + + const newArray = [ + ...oldArray.slice(0, param), + newValue, + ...oldArray.slice(param + 1), + ]; + + return newArray; + }); + }, + ), + ); + + const Displayer = ({ + index, + countAtom, + }: { + index: number; + countAtom: WritableAtom], void>; + }) => { + const [count, setCount] = useAtom(countAtom); + return ( +
+ index: {index}, count: {count} + +
+ ); + }; + + const indicesAtom = atom((get) => [...new Array(get(arrayAtom).length)]); + + const Parent = () => { + const [indices] = useAtom(indicesAtom); + + return ( +
+ {indices.map((_, index) => ( + + ))} +
+ ); + }; + + const { getByText } = render( + + + , + ); + + await waitFor(() => { + getByText('index: 0, count: 0'); + getByText('index: 1, count: 0'); + getByText('index: 2, count: 0'); + }); + + await userEvent.click(getByText('increment #1')); + await waitFor(() => { + getByText('index: 0, count: 0'); + getByText('index: 1, count: 1'); + getByText('index: 2, count: 0'); + }); + + await userEvent.click(getByText('increment #0')); + await waitFor(() => { + getByText('index: 0, count: 1'); + getByText('index: 1, count: 1'); + getByText('index: 2, count: 0'); + }); + + await userEvent.click(getByText('increment #2')); + await waitFor(() => { + getByText('index: 0, count: 1'); + getByText('index: 1, count: 1'); + getByText('index: 2, count: 1'); + }); +}); + +it('custom equality function work', async () => { + const bigAtom = atom([0]); + + const badFamily = atomFamily((num: { index: number }) => + atom((get) => get(bigAtom)[num.index] as number), + ); + + const goodFamily = atomFamily( + (num: { index: number }) => + atom((get) => get(bigAtom)[num.index] as number), + (l, r) => l.index === r.index, + ); + + expect(badFamily({ index: 0 })).not.toEqual(badFamily({ index: 0 })); + expect(badFamily({ index: 0 })).not.toEqual(badFamily({ index: 0 })); + + expect(goodFamily({ index: 0 })).toEqual(goodFamily({ index: 0 })); + expect(goodFamily({ index: 0 })).not.toEqual(goodFamily({ index: 1 })); +}); + +it('a derived atom from an async atomFamily (#351)', async () => { + const countAtom = atom(1); + const resolve: (() => void)[] = []; + const getAsyncAtom = atomFamily((n: number) => + atom(async () => { + await new Promise((r) => resolve.push(r)); + return n + 10; + }), + ); + const derivedAtom = atom((get) => get(getAsyncAtom(get(countAtom)))); + + const Counter = () => { + const setCount = useSetAtom(countAtom); + const [derived] = useAtom(derivedAtom); + return ( + <> +
derived: {derived}
+ + + ); + }; + + const { getByText, findByText } = render( + + + + + , + ); + + await findByText('loading'); + resolve.splice(0).forEach((fn) => fn()); + await findByText('derived: 11'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve.splice(0).forEach((fn) => fn()); + await findByText('derived: 12'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve.splice(0).forEach((fn) => fn()); + await findByText('derived: 13'); +}); + +it('setShouldRemove with custom equality function', async () => { + const myFamily = atomFamily( + (num: { index: number }) => atom(num), + (l, r) => l.index === r.index, + ); + let firstTime = true; + myFamily.setShouldRemove(() => { + if (firstTime) { + firstTime = false; + return true; + } + return false; + }); + + const family1 = myFamily({ index: 0 }); + const family2 = myFamily({ index: 0 }); + const family3 = myFamily({ index: 0 }); + + expect(family1).not.toBe(family2); + expect(family2).toBe(family3); +}); diff --git a/__tests__/derive/baseTests/react/vanilla-utils/atomWithDefault.test.tsx b/__tests__/derive/baseTests/react/vanilla-utils/atomWithDefault.test.tsx new file mode 100644 index 0000000..13fe1bc --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/atomWithDefault.test.tsx @@ -0,0 +1,206 @@ +import { StrictMode, Suspense } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom } from '../../../../../jotai/react'; +import { atom } from '../../../../../jotai/vanilla'; +import { RESET, atomWithDefault } from '../../../../../jotai/vanilla/utils'; + +it('simple sync get default', async () => { + const count1Atom = atom(1); + const count2Atom = atomWithDefault((get) => get(count1Atom) * 2); + + const Counter = () => { + const [count1, setCount1] = useAtom(count1Atom); + const [count2, setCount2] = useAtom(count2Atom); + return ( + <> +
+ count1: {count1}, count2: {count2} +
+ + + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count1: 1, count2: 2'); + + await userEvent.click(getByText('button1')); + await findByText('count1: 2, count2: 4'); + + await userEvent.click(getByText('button2')); + await findByText('count1: 2, count2: 5'); + + await userEvent.click(getByText('button1')); + await findByText('count1: 3, count2: 5'); +}); + +it('simple async get default', async () => { + const count1Atom = atom(1); + let resolve = () => {}; + const count2Atom = atomWithDefault(async (get) => { + await new Promise((r) => (resolve = r)); + return get(count1Atom) * 2; + }); + + const Counter = () => { + const [count1, setCount1] = useAtom(count1Atom); + const [count2, setCount2] = useAtom(count2Atom); + return ( + <> +
+ count1: {count1}, count2: {count2} +
+ + + + ); + }; + + const { findByText, getByText } = render( + + + + + , + ); + + await findByText('loading'); + resolve(); + await findByText('count1: 1, count2: 2'); + + await userEvent.click(getByText('button1')); + await findByText('loading'); + resolve(); + await findByText('count1: 2, count2: 4'); + + await userEvent.click(getByText('button2')); + resolve(); + await findByText('count1: 2, count2: 5'); + + await userEvent.click(getByText('button1')); + resolve(); + await findByText('count1: 3, count2: 5'); +}); + +it('refresh sync atoms to default values', async () => { + const count1Atom = atom(1); + const count2Atom = atomWithDefault((get) => get(count1Atom) * 2); + + const Counter = () => { + const [count1, setCount1] = useAtom(count1Atom); + const [count2, setCount2] = useAtom(count2Atom); + return ( + <> +
+ count1: {count1}, count2: {count2} +
+ + + + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count1: 1, count2: 2'); + + await userEvent.click(getByText('button1')); + await findByText('count1: 2, count2: 4'); + + await userEvent.click(getByText('button2')); + await findByText('count1: 2, count2: 5'); + + await userEvent.click(getByText('button1')); + await findByText('count1: 3, count2: 5'); + + await userEvent.click(getByText('Refresh count2')); + await findByText('count1: 3, count2: 6'); + + await userEvent.click(getByText('button1')); + await findByText('count1: 4, count2: 8'); +}); + +it('refresh async atoms to default values', async () => { + const count1Atom = atom(1); + let resolve = () => {}; + const count2Atom = atomWithDefault(async (get) => { + await new Promise((r) => (resolve = r)); + return get(count1Atom) * 2; + }); + + const Counter = () => { + const [count1, setCount1] = useAtom(count1Atom); + const [count2, setCount2] = useAtom(count2Atom); + return ( + <> +
+ count1: {count1}, count2: {count2} +
+ + + + + ); + }; + + const { findByText, getByText } = render( + + + + + , + ); + + await findByText('loading'); + await waitFor(() => { + resolve(); + getByText('count1: 1, count2: 2'); + }); + + await userEvent.click(getByText('button1')); + await findByText('loading'); + await waitFor(() => { + resolve(); + getByText('count1: 2, count2: 4'); + }); + + await userEvent.click(getByText('button2')); + await waitFor(() => { + resolve(); + getByText('count1: 2, count2: 5'); + }); + + await userEvent.click(getByText('button1')); + await waitFor(() => { + resolve(); + getByText('count1: 3, count2: 5'); + }); + + await userEvent.click(getByText('Refresh count2')); + await waitFor(() => { + resolve(); + getByText('count1: 3, count2: 6'); + }); + + await userEvent.click(getByText('button1')); + await waitFor(() => { + resolve(); + getByText('count1: 4, count2: 8'); + }); +}); diff --git a/__tests__/derive/baseTests/react/vanilla-utils/atomWithReducer.test.tsx b/__tests__/derive/baseTests/react/vanilla-utils/atomWithReducer.test.tsx new file mode 100644 index 0000000..b40c49b --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/atomWithReducer.test.tsx @@ -0,0 +1,85 @@ +import { StrictMode } from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom } from '../../../../../jotai/react'; +import { atomWithReducer } from '../../../../../jotai/vanilla/utils'; + +it('atomWithReducer with optional action argument', async () => { + const reducer = (state: number, action?: 'INCREASE' | 'DECREASE') => { + switch (action) { + case 'INCREASE': + return state + 1; + case 'DECREASE': + return state - 1; + case undefined: + return state; + } + }; + const countAtom = atomWithReducer(0, reducer); + + const Parent = () => { + const [count, dispatch] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('dispatch INCREASE')); + await findByText('count: 1'); + + await userEvent.click(getByText('dispatch empty')); + await findByText('count: 1'); + + await userEvent.click(getByText('dispatch DECREASE')); + await findByText('count: 0'); +}); + +it('atomWithReducer with non-optional action argument', async () => { + const reducer = (state: number, action: 'INCREASE' | 'DECREASE') => { + switch (action) { + case 'INCREASE': + return state + 1; + case 'DECREASE': + return state - 1; + } + }; + const countAtom = atomWithReducer(0, reducer); + + const Parent = () => { + const [count, dispatch] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 0'); + + await userEvent.click(getByText('dispatch INCREASE')); + await findByText('count: 1'); + + await userEvent.click(getByText('dispatch DECREASE')); + await findByText('count: 0'); +}); diff --git a/__tests__/derive/baseTests/react/vanilla-utils/atomWithRefresh.test.tsx b/__tests__/derive/baseTests/react/vanilla-utils/atomWithRefresh.test.tsx new file mode 100644 index 0000000..7e73d49 --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/atomWithRefresh.test.tsx @@ -0,0 +1,119 @@ +import { StrictMode, Suspense } from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom } from '../../../../../jotai/react'; +import { atomWithRefresh } from '../../../../../jotai/vanilla/utils'; + +it('sync counter', async () => { + let counter = 0; + const countAtom = atomWithRefresh(() => ++counter); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 1'); + + await userEvent.click(getByText('button')); + await findByText('count: 2'); + + await userEvent.click(getByText('button')); + await findByText('count: 3'); + + expect(counter).toBe(3); +}); + +it('async counter', async () => { + let resolve = () => {}; + let counter = 0; + const countAtom = atomWithRefresh(async () => { + await new Promise((r) => (resolve = r)); + return ++counter; + }); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + ); + }; + + const { findByText, getByText } = render( + + + + + , + ); + + await findByText('loading'); + resolve(); + await findByText('count: 1'); + + await userEvent.click(getByText('button')); + await findByText('loading'); + resolve(); + await findByText('count: 2'); + + await userEvent.click(getByText('button')); + resolve(); + await findByText('count: 3'); + + expect(counter).toBe(3); +}); + +it('writable counter', async () => { + let counter = 0; + const countAtom = atomWithRefresh( + () => ++counter, + (_get, _set, newValue: number) => { + counter = newValue; + }, + ); + + const Counter = () => { + const [count, setCount] = useAtom(countAtom); + return ( + <> +
count: {count}
+ + + + ); + }; + + const { findByText, getByText } = render( + + + , + ); + + await findByText('count: 1'); + + await userEvent.click(getByText('button')); + await findByText('count: 2'); + + await userEvent.click(getByText('button')); + await findByText('count: 3'); + + await userEvent.click(getByText('set9')); + await findByText('count: 3'); + + await userEvent.click(getByText('button')); + await findByText('count: 10'); +}); diff --git a/__tests__/derive/baseTests/react/vanilla-utils/freezeAtom.test.tsx b/__tests__/derive/baseTests/react/vanilla-utils/freezeAtom.test.tsx new file mode 100644 index 0000000..5a2ed1a --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/freezeAtom.test.tsx @@ -0,0 +1,79 @@ +import { StrictMode } from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom } from '../../../../../jotai/react'; +import { atom } from '../../../../../jotai/vanilla'; +import { + freezeAtom, + freezeAtomCreator, +} from '../../../../../jotai/vanilla/utils'; + +it('freezeAtom basic test', async () => { + const objAtom = atom({ deep: {} }, (_get, set, _ignored?) => { + set(objAtom, { deep: {} }); + }); + + const Component = () => { + const [obj, setObj] = useAtom(freezeAtom(objAtom)); + return ( + <> + +
+ isFrozen: {`${Object.isFrozen(obj) && Object.isFrozen(obj.deep)}`} +
+ + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('isFrozen: true'); + + await userEvent.click(getByText('change')); + await findByText('isFrozen: true'); +}); + +describe('freezeAtomCreator', () => { + let savedConsoleWarn: any; + beforeEach(() => { + savedConsoleWarn = console.warn; + console.warn = jest.fn(); + }); + afterEach(() => { + console.warn = savedConsoleWarn; + }); + + it('freezeAtomCreator basic test', async () => { + const createFrozenAtom = freezeAtomCreator(atom); + const objAtom = createFrozenAtom({ deep: {} }, (_get, set, _ignored?) => { + set(objAtom, { deep: {} }); + }); + + const Component = () => { + const [obj, setObj] = useAtom(objAtom); + return ( + <> + +
+ isFrozen: {`${Object.isFrozen(obj) && Object.isFrozen(obj.deep)}`} +
+ + ); + }; + + const { getByText, findByText } = render( + + + , + ); + + await findByText('isFrozen: true'); + + await userEvent.click(getByText('change')); + await findByText('isFrozen: true'); + }); +}); diff --git a/__tests__/derive/baseTests/react/vanilla-utils/loadable.test.tsx b/__tests__/derive/baseTests/react/vanilla-utils/loadable.test.tsx new file mode 100644 index 0000000..1901d56 --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/loadable.test.tsx @@ -0,0 +1,307 @@ +import { StrictMode, Suspense, useEffect } from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtomValue, useSetAtom } from '../../../../../jotai/react'; +import { atom } from '../../../../../jotai/vanilla'; +import type { Atom } from '../../../../../jotai/vanilla'; +import { loadable } from '../../../../../jotai/vanilla/utils'; + +it('loadable turns suspense into values', async () => { + let resolve: (x: number) => void = () => {}; + const asyncAtom = atom(() => { + return new Promise((r) => (resolve = r)); + }); + + const { findByText } = render( + + + , + ); + + await findByText('Loading...'); + resolve(5); + await findByText('Data: 5'); +}); + +it('loadable turns errors into values', async () => { + let reject: (error: unknown) => void = () => {}; + const asyncAtom = atom(() => { + return new Promise((_res, rej) => (reject = rej)); + }); + + const { findByText } = render( + + + , + ); + + await findByText('Loading...'); + reject(new Error('An error occurred')); + await findByText('Error: An error occurred'); +}); + +it('loadable turns primitive throws into values', async () => { + let reject: (error: unknown) => void = () => {}; + const asyncAtom = atom(() => { + return new Promise((_res, rej) => (reject = rej)); + }); + + const { findByText } = render( + + + , + ); + + await findByText('Loading...'); + reject('An error occurred'); + await findByText('An error occurred'); +}); + +it('loadable goes back to loading after re-fetch', async () => { + let resolve: (x: number) => void = () => {}; + const refreshAtom = atom(0); + const asyncAtom = atom((get) => { + get(refreshAtom); + return new Promise((r) => (resolve = r)); + }); + + const Refresh = () => { + const setRefresh = useSetAtom(refreshAtom); + return ( + <> + + + ); + }; + + const { findByText, getByText } = render( + + + + , + ); + + getByText('Loading...'); + resolve(5); + await findByText('Data: 5'); + await userEvent.click(getByText('refresh')); + await findByText('Loading...'); + resolve(6); + await findByText('Data: 6'); +}); + +it('loadable can recover from error', async () => { + let resolve: (x: number) => void = () => {}; + let reject: (error: unknown) => void = () => {}; + const refreshAtom = atom(0); + const asyncAtom = atom((get) => { + get(refreshAtom); + return new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + }); + + const Refresh = () => { + const setRefresh = useSetAtom(refreshAtom); + return ( + <> + + + ); + }; + + const { findByText, getByText } = render( + + + + , + ); + + getByText('Loading...'); + reject(new Error('An error occurred')); + await findByText('Error: An error occurred'); + await userEvent.click(getByText('refresh')); + await findByText('Loading...'); + resolve(6); + await findByText('Data: 6'); +}); + +it('loadable immediately resolves sync values', async () => { + const syncAtom = atom(5); + const effectCallback = jest.fn(); + + const { getByText } = render( + + + , + ); + + getByText('Data: 5'); + expect(effectCallback.mock.calls).not.toContain( + expect.objectContaining({ state: 'loading' }), + ); + expect(effectCallback).toHaveBeenLastCalledWith({ + state: 'hasData', + data: 5, + }); +}); + +it('loadable can use resolved promises synchronously', async () => { + const asyncAtom = atom(Promise.resolve(5)); + const effectCallback = jest.fn(); + + const ResolveAtomComponent = () => { + useAtomValue(asyncAtom); + + return
Ready
; + }; + + const { findByText, rerender } = render( + + + + + , + ); + + await findByText('Ready'); + + rerender( + + + , + ); + await findByText('Data: 5'); + + expect(effectCallback.mock.calls).not.toContain( + expect.objectContaining({ state: 'loading' }), + ); + expect(effectCallback).toHaveBeenLastCalledWith({ + state: 'hasData', + data: 5, + }); +}); + +it('loadable of a derived async atom does not trigger infinite loop (#1114)', async () => { + let resolve: (x: number) => void = () => {}; + const baseAtom = atom(0); + const asyncAtom = atom((get) => { + get(baseAtom); + return new Promise((r) => (resolve = r)); + }); + + const Trigger = () => { + const trigger = useSetAtom(baseAtom); + return ( + <> + + + ); + }; + + const { findByText, getByText } = render( + + + + , + ); + + getByText('Loading...'); + await userEvent.click(getByText('trigger')); + resolve(5); + await findByText('Data: 5'); +}); + +it('loadable of a derived async atom with error does not trigger infinite loop (#1330)', async () => { + const baseAtom = atom(() => { + throw new Error('thrown in baseAtom'); + }); + const asyncAtom = atom(async (get) => { + get(baseAtom); + return ''; + }); + + const { findByText, getByText } = render( + + + , + ); + + getByText('Loading...'); + await findByText('Error: thrown in baseAtom'); +}); + +it('does not repeatedly attempt to get the value of an unresolved promise atom wrapped in a loadable (#1481)', async () => { + const baseAtom = atom(new Promise(() => {})); + + let callsToGetBaseAtom = 0; + const derivedAtom = atom((get) => { + callsToGetBaseAtom++; + return get(baseAtom); + }); + + render( + + + , + ); + + // we need a small delay to reproduce the issue + await new Promise((r) => setTimeout(r, 10)); + // depending on provider-less mode or versioned-write mode, there will be + // either 2 or 3 calls. + expect(callsToGetBaseAtom).toBeLessThanOrEqual(3); +}); + +it('should handle sync error (#1843)', async () => { + const syncAtom = atom(() => { + throw new Error('thrown in syncAtom'); + }); + + const { findByText } = render( + + + , + ); + + await findByText('Error: thrown in syncAtom'); +}); + +type LoadableComponentProps = { + asyncAtom: Atom | Promise | string | number>; + effectCallback?: (loadableValue: any) => void; +}; + +const LoadableComponent = ({ + asyncAtom, + effectCallback, +}: LoadableComponentProps) => { + const value = useAtomValue(loadable(asyncAtom)); + + useEffect(() => { + if (effectCallback) { + effectCallback(value); + } + }, [value, effectCallback]); + + if (value.state === 'loading') { + return <>Loading...; + } + + if (value.state === 'hasError') { + return <>{String(value.error)}; + } + + // this is to ensure correct typing + const data: number | string = value.data; + + return <>Data: {data}; +}; diff --git a/__tests__/derive/baseTests/react/vanilla-utils/selectAtom.test.tsx b/__tests__/derive/baseTests/react/vanilla-utils/selectAtom.test.tsx new file mode 100644 index 0000000..95eeb42 --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/selectAtom.test.tsx @@ -0,0 +1,131 @@ +import { StrictMode, useEffect, useRef } from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtomValue, useSetAtom } from '../../../../../jotai/react'; +import { atom } from '../../../../../jotai/vanilla'; +import { selectAtom } from '../../../../../jotai/vanilla/utils'; + +const useCommitCount = () => { + const commitCountRef = useRef(1); + useEffect(() => { + commitCountRef.current += 1; + }); + return commitCountRef.current; +}; + +it('selectAtom works as expected', async () => { + const bigAtom = atom({ a: 0, b: 'othervalue' }); + const littleAtom = selectAtom(bigAtom, (v) => v.a); + + const Parent = () => { + const setValue = useSetAtom(bigAtom); + return ( + <> + + + ); + }; + + const Selector = () => { + const a = useAtomValue(littleAtom); + return ( + <> +
a: {a}
+ + ); + }; + + const { findByText, getByText } = render( + + + + , + ); + + await findByText('a: 0'); + + await userEvent.click(getByText('increment')); + await findByText('a: 1'); + await userEvent.click(getByText('increment')); + await findByText('a: 2'); + await userEvent.click(getByText('increment')); + await findByText('a: 3'); +}); + +it('do not update unless equality function says value has changed', async () => { + const bigAtom = atom({ a: 0 }); + const littleAtom = selectAtom( + bigAtom, + (value) => value, + (left, right) => JSON.stringify(left) === JSON.stringify(right), + ); + + const Parent = () => { + const setValue = useSetAtom(bigAtom); + return ( + <> + + + + ); + }; + + const Selector = () => { + const value = useAtomValue(littleAtom); + const commits = useCommitCount(); + return ( + <> +
value: {JSON.stringify(value)}
+
commits: {commits}
+ + ); + }; + + const { findByText, getByText } = render( + <> + + + , + ); + + await findByText('value: {"a":0}'); + await findByText('commits: 1'); + await userEvent.click(getByText('copy')); + await findByText('value: {"a":0}'); + await findByText('commits: 1'); + + await userEvent.click(getByText('increment')); + await findByText('value: {"a":1}'); + await findByText('commits: 2'); + await userEvent.click(getByText('copy')); + await findByText('value: {"a":1}'); + await findByText('commits: 2'); + + await userEvent.click(getByText('increment')); + await findByText('value: {"a":2}'); + await findByText('commits: 3'); + await userEvent.click(getByText('copy')); + await findByText('value: {"a":2}'); + await findByText('commits: 3'); + + await userEvent.click(getByText('increment')); + await findByText('value: {"a":3}'); + await findByText('commits: 4'); + await userEvent.click(getByText('copy')); + await findByText('value: {"a":3}'); + await findByText('commits: 4'); +}); diff --git a/__tests__/derive/baseTests/react/vanilla-utils/splitAtom.test.tsx b/__tests__/derive/baseTests/react/vanilla-utils/splitAtom.test.tsx new file mode 100644 index 0000000..5d3add1 --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/splitAtom.test.tsx @@ -0,0 +1,555 @@ +import { StrictMode, useEffect, useRef } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useAtom, useAtomValue, useSetAtom } from '../../../../../jotai/react'; +import { atom } from '../../../../../jotai/vanilla'; +import type { Atom, PrimitiveAtom } from '../../../../../jotai/vanilla'; +import { splitAtom } from '../../../../../jotai/vanilla/utils'; + +type TodoItem = { task: string; checked?: boolean }; + +const useCommitCount = () => { + const commitCountRef = useRef(1); + useEffect(() => { + commitCountRef.current += 1; + }); + return commitCountRef.current; +}; + +it('no unnecessary updates when updating atoms', async () => { + const todosAtom = atom([ + { task: 'get cat food', checked: false }, + { task: 'get dragon food', checked: false }, + ]); + + const TaskList = ({ listAtom }: { listAtom: typeof todosAtom }) => { + const [atoms, dispatch] = useAtom(splitAtom(listAtom)); + return ( + <> + TaskListUpdates: {useCommitCount()} + {atoms.map((anAtom) => ( + dispatch({ type: 'remove', atom: anAtom })} + itemAtom={anAtom} + /> + ))} + + ); + }; + + const TaskItem = ({ + itemAtom, + }: { + itemAtom: PrimitiveAtom; + onRemove: () => void; + }) => { + const [value, onChange] = useAtom(itemAtom); + const toggle = () => + onChange((value) => ({ ...value, checked: !value.checked })); + return ( +
  • + {value.task} commits: {useCommitCount()} + +
  • + ); + }; + + const { getByTestId, getByText } = render( + <> + + , + ); + + await waitFor(() => { + getByText('TaskListUpdates: 1'); + getByText('get cat food commits: 1'); + getByText('get dragon food commits: 1'); + }); + + const catBox = getByTestId('get cat food-checkbox') as HTMLInputElement; + const dragonBox = getByTestId('get dragon food-checkbox') as HTMLInputElement; + + expect(catBox.checked).toBeFalsy(); + expect(dragonBox.checked).toBeFalsy(); + + await userEvent.click(catBox); + + await waitFor(() => { + getByText('TaskListUpdates: 1'); + getByText('get cat food commits: 2'); + getByText('get dragon food commits: 1'); + }); + + expect(catBox.checked).toBeTruthy(); + expect(dragonBox.checked).toBeFalsy(); + + await userEvent.click(dragonBox); + + await waitFor(() => { + getByText('TaskListUpdates: 1'); + getByText('get cat food commits: 2'); + getByText('get dragon food commits: 2'); + }); + + expect(catBox.checked).toBeTruthy(); + expect(dragonBox.checked).toBeTruthy(); +}); + +it('removing atoms', async () => { + const todosAtom = atom([ + { task: 'get cat food', checked: false }, + { task: 'get dragon food', checked: false }, + { task: 'help nana', checked: false }, + ]); + + const TaskList = ({ listAtom }: { listAtom: typeof todosAtom }) => { + const [atoms, dispatch] = useAtom(splitAtom(listAtom)); + return ( + <> + {atoms.map((anAtom) => ( + dispatch({ type: 'remove', atom: anAtom })} + itemAtom={anAtom} + /> + ))} + + ); + }; + + const TaskItem = ({ + itemAtom, + onRemove, + }: { + itemAtom: PrimitiveAtom; + onRemove: () => void; + }) => { + const [value] = useAtom(itemAtom); + return ( +
  • +
    {value.task}
    + +
  • + ); + }; + + const { getByTestId, queryByText } = render( + + + , + ); + + await waitFor(() => { + expect(queryByText('get cat food')).toBeTruthy(); + expect(queryByText('get dragon food')).toBeTruthy(); + expect(queryByText('help nana')).toBeTruthy(); + }); + + await userEvent.click(getByTestId('get cat food-removebutton')); + + await waitFor(() => { + expect(queryByText('get cat food')).toBeFalsy(); + expect(queryByText('get dragon food')).toBeTruthy(); + expect(queryByText('help nana')).toBeTruthy(); + }); + + await userEvent.click(getByTestId('get dragon food-removebutton')); + + await waitFor(() => { + expect(queryByText('get cat food')).toBeFalsy(); + expect(queryByText('get dragon food')).toBeFalsy(); + expect(queryByText('help nana')).toBeTruthy(); + }); + + await userEvent.click(getByTestId('help nana-removebutton')); + + await waitFor(() => { + expect(queryByText('get cat food')).toBeFalsy(); + expect(queryByText('get dragon food')).toBeFalsy(); + expect(queryByText('help nana')).toBeFalsy(); + }); +}); + +it('inserting atoms', async () => { + const todosAtom = atom([ + { task: 'get cat food' }, + { task: 'get dragon food' }, + { task: 'help nana' }, + ]); + + const TaskList = ({ listAtom }: { listAtom: typeof todosAtom }) => { + const [atoms, dispatch] = useAtom(splitAtom(listAtom)); + return ( + <> +
      + {atoms.map((anAtom) => ( + + dispatch({ + type: 'insert', + value: newValue, + before: anAtom, + }) + } + itemAtom={anAtom} + /> + ))} +
    + + + ); + }; + + let taskCount = 1; + const TaskItem = ({ + itemAtom, + onInsert, + }: { + itemAtom: PrimitiveAtom; + onInsert: (newValue: TodoItem) => void; + }) => { + const [value] = useAtom(itemAtom); + return ( +
  • +
    {value.task}
    + +
  • + ); + }; + + const { getByTestId, queryByTestId } = render( + + + , + ); + + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'get cat food+get dragon food+help nana+', + ); + }); + + await userEvent.click(getByTestId('help nana-insertbutton')); + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'get cat food+get dragon food+new task1+help nana+', + ); + }); + + await userEvent.click(getByTestId('get cat food-insertbutton')); + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'new task2+get cat food+get dragon food+new task1+help nana+', + ); + }); + + await userEvent.click(getByTestId('addtaskbutton')); + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'new task2+get cat food+get dragon food+new task1+help nana+end+', + ); + }); +}); + +it('moving atoms', async () => { + const todosAtom = atom([ + { task: 'get cat food' }, + { task: 'get dragon food' }, + { task: 'help nana' }, + ]); + + const TaskList = ({ listAtom }: { listAtom: typeof todosAtom }) => { + const [atoms, dispatch] = useAtom(splitAtom(listAtom)); + return ( +
      + {atoms.map((anAtom, index) => ( + { + if (index === 0) { + dispatch({ + type: 'move', + atom: anAtom, + }); + } else if (index > 0) { + dispatch({ + type: 'move', + atom: anAtom, + before: atoms[index - 1] as PrimitiveAtom, + }); + } + }} + onMoveRight={() => { + if (index === atoms.length - 1) { + dispatch({ + type: 'move', + atom: anAtom, + }); + } else if (index < atoms.length - 1) { + dispatch({ + type: 'move', + atom: anAtom, + before: atoms[index + 2] as PrimitiveAtom, + }); + } + }} + itemAtom={anAtom} + /> + ))} +
    + ); + }; + + const TaskItem = ({ + itemAtom, + onMoveLeft, + onMoveRight, + }: { + itemAtom: PrimitiveAtom; + onMoveLeft: () => void; + onMoveRight: () => void; + }) => { + const [value] = useAtom(itemAtom); + return ( +
  • +
    {value.task}
    + + +
  • + ); + }; + + const { getByTestId, queryByTestId } = render( + + + , + ); + + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'get cat food<>get dragon food<>help nana<>', + ); + }); + + await userEvent.click(getByTestId('help nana-leftbutton')); + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'get cat food<>help nana<>get dragon food<>', + ); + }); + + await userEvent.click(getByTestId('get cat food-rightbutton')); + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'help nana<>get cat food<>get dragon food<>', + ); + }); + + await userEvent.click(getByTestId('get cat food-rightbutton')); + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'help nana<>get dragon food<>get cat food<>', + ); + }); + + await userEvent.click(getByTestId('help nana-leftbutton')); + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'get dragon food<>get cat food<>help nana<>', + ); + }); +}); + +it('read-only array atom', async () => { + const todosAtom = atom(() => [ + { task: 'get cat food', checked: false }, + { task: 'get dragon food', checked: false }, + ]); + + const TaskList = ({ listAtom }: { listAtom: typeof todosAtom }) => { + const [atoms] = useAtom(splitAtom(listAtom)); + return ( + <> + {atoms.map((anAtom) => ( + + ))} + + ); + }; + + const TaskItem = ({ itemAtom }: { itemAtom: Atom }) => { + const [value] = useAtom(itemAtom); + return ( +
  • + +
  • + ); + }; + + const { getByTestId } = render( + + + , + ); + + const catBox = getByTestId('get cat food-checkbox') as HTMLInputElement; + const dragonBox = getByTestId('get dragon food-checkbox') as HTMLInputElement; + + await waitFor(() => { + expect(catBox.checked).toBeFalsy(); + expect(dragonBox.checked).toBeFalsy(); + }); +}); + +it('no error with cached atoms (fix 510)', async () => { + const filterAtom = atom('all'); + const numsAtom = atom([0, 1, 2, 3, 4]); + const filteredAtom = atom((get) => { + const filter = get(filterAtom); + const nums = get(numsAtom); + if (filter === 'even') { + return nums.filter((num) => num % 2 === 0); + } + return nums; + }); + const filteredAtomsAtom = splitAtom(filteredAtom, (num) => num); + + function useCachedAtoms(atoms: T[]) { + const prevAtoms = useRef(atoms); + return prevAtoms.current; + } + + type NumItemProps = { atom: Atom }; + + const NumItem = ({ atom }: NumItemProps) => { + const [readOnlyItem] = useAtom(atom); + if (typeof readOnlyItem !== 'number') { + throw new Error('expecting a number'); + } + return <>{readOnlyItem}; + }; + + function Filter() { + const [, setFilter] = useAtom(filterAtom); + return ; + } + + const Filtered = () => { + const [todos] = useAtom(filteredAtomsAtom); + const cachedAtoms = useCachedAtoms(todos); + + return ( + <> + {cachedAtoms.map((atom) => ( + + ))} + + ); + }; + + const { getByText } = render( + + + + , + ); + + await userEvent.click(getByText('button')); +}); + +it('variable sized splitted atom', async () => { + const lengthAtom = atom(3); + const collectionAtom = atom([]); + const collectionAtomsAtom = splitAtom(collectionAtom); + const derivativeAtom = atom((get) => + get(collectionAtomsAtom).map((ca) => get(ca)), + ); + + function App() { + const [length, setLength] = useAtom(lengthAtom); + const setCollection = useSetAtom(collectionAtom); + const [derivative] = useAtom(derivativeAtom); + useEffect(() => { + setCollection([1, 2, 3].splice(0, length)); + }, [length, setCollection]); + return ( +
    + + numbers: {derivative.join(',')} +
    + ); + } + + const { findByText, getByText } = render( + + + , + ); + + await findByText('numbers: 1,2,3'); + + await userEvent.click(getByText('button')); + await findByText('numbers: 1,2'); +}); + +it('should not update splitted atom when single item is set to identical value', async () => { + const initialCollection = [1, 2, 3]; + const collectionAtom = atom(initialCollection); + const collectionAtomsAtom = splitAtom(collectionAtom); + + function App() { + const collectionAtoms = useAtomValue(collectionAtomsAtom); + const setItem2 = useSetAtom(collectionAtoms[1]!); + const currentCollection = useAtomValue(collectionAtom); + return ( +
    + + changed: {(!Object.is(currentCollection, initialCollection)).toString()} +
    + ); + } + + const { findByText, getByText } = render( + + + , + ); + + await findByText('changed: false'); + + await userEvent.click(getByText('button')); + await findByText('changed: false'); +}); diff --git a/__tests__/derive/baseTests/vanilla/basic.test.tsx b/__tests__/derive/baseTests/vanilla/basic.test.tsx new file mode 100644 index 0000000..25e141a --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/basic.test.tsx @@ -0,0 +1,66 @@ +import { atom } from '../../../../jotai/vanilla'; + +it('creates atoms', () => { + // primitive atom + const countAtom = atom(0); + const anotherCountAtom = atom(1); + // read-only derived atom + const doubledCountAtom = atom((get) => get(countAtom) * 2); + // read-write derived atom + const sumCountAtom = atom( + (get) => get(countAtom) + get(anotherCountAtom), + (get, set, value: number) => { + set(countAtom, get(countAtom) + value / 2); + set(anotherCountAtom, get(anotherCountAtom) + value / 2); + }, + ); + // write-only derived atom + const decrementCountAtom = atom(null, (get, set) => { + set(countAtom, get(countAtom) - 1); + }); + expect({ + countAtom, + doubledCountAtom, + sumCountAtom, + decrementCountAtom, + }).toMatchInlineSnapshot(` + { + "countAtom": { + "init": 0, + "read": [Function], + "toString": [Function], + "write": [Function], + }, + "decrementCountAtom": { + "init": null, + "read": [Function], + "toString": [Function], + "write": [Function], + }, + "doubledCountAtom": { + "read": [Function], + "toString": [Function], + }, + "sumCountAtom": { + "read": [Function], + "toString": [Function], + "write": [Function], + }, + } + `); +}); + +it('should let users mark atoms as private', () => { + const internalAtom = atom(0); + internalAtom.debugPrivate = true; + + expect(internalAtom).toMatchInlineSnapshot(` + { + "debugPrivate": true, + "init": 0, + "read": [Function], + "toString": [Function], + "write": [Function], + } + `); +}); diff --git a/__tests__/derive/baseTests/vanilla/dependency.test.tsx b/__tests__/derive/baseTests/vanilla/dependency.test.tsx new file mode 100644 index 0000000..c6066ce --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/dependency.test.tsx @@ -0,0 +1,276 @@ +import { atom } from '../../../../jotai/vanilla'; +import { createStore } from '../../derivedStore'; + +it('can propagate updates with async atom chains', async () => { + const store = createStore(); + + const countAtom = atom(1); + let resolve = () => {}; + const asyncAtom = atom(async (get) => { + const count = get(countAtom); + await new Promise((r) => (resolve = r)); + return count; + }); + const async2Atom = atom((get) => get(asyncAtom)); + const async3Atom = atom((get) => get(async2Atom)); + + expect(store.get(async3Atom) instanceof Promise).toBeTruthy(); + resolve(); + await expect(store.get(async3Atom)).resolves.toBe(1); + + store.set(countAtom, (c) => c + 1); + expect(store.get(async3Atom) instanceof Promise).toBeTruthy(); + resolve(); + await expect(store.get(async3Atom)).resolves.toBe(2); + + store.set(countAtom, (c) => c + 1); + expect(store.get(async3Atom) instanceof Promise).toBeTruthy(); + resolve(); + await expect(store.get(async3Atom)).resolves.toBe(3); +}); + +it('can get async atom with deps more than once before resolving (#1668)', async () => { + const countAtom = atom(0); + + const resolve: (() => void)[] = []; + const asyncAtom = atom(async (get) => { + const count = get(countAtom); + await new Promise((r) => resolve.push(r)); + return count; + }); + + const store = createStore(); + + store.set(countAtom, (c) => c + 1); + store.get(asyncAtom); + store.set(countAtom, (c) => c + 1); + const promise = store.get(asyncAtom); + resolve.shift()?.(); + await Promise.resolve(); + resolve.shift()?.(); + const count = await promise; + expect(count).toBe(2); +}); + +it('correctly updates async derived atom after get/set update', async () => { + const baseAtom = atom(0); + const derivedAsyncAtom = atom( + async (get) => get(baseAtom) + 1, + async (_get, set, val) => set(baseAtom, val as number), + ); + + const store = createStore(); + + // NOTE: Have to .set() straight after await on .get(), so that it executes + // in the same JS event loop cycle! + let derived = await store.get(derivedAsyncAtom); + await store.set(derivedAsyncAtom, 2); + + expect(derived).toBe(1); + expect(store.get(baseAtom)).toBe(2); + + derived = await store.get(derivedAsyncAtom); + expect(derived).toBe(3); +}); + +it('correctly handles the same promise being returned twice from an atom getter (#2151)', async () => { + const asyncDataAtom = atom(async () => { + return 'Asynchronous Data'; + }); + + const counterAtom = atom(0); + + const derivedAtom = atom((get) => { + get(counterAtom); // depending on sync data + return get(asyncDataAtom); // returning a promise from another atom + }); + + const store = createStore(); + + store.get(derivedAtom); + // setting the `counterAtom` dependency on the same JS event loop cycle, before + // the `derivedAtom` promise resolves. + store.set(counterAtom, 1); + await expect(store.get(derivedAtom)).resolves.toBe('Asynchronous Data'); +}); + +it('keeps atoms mounted between recalculations', async () => { + const metrics1 = { + mounted: 0, + unmounted: 0, + }; + const atom1 = atom(0); + atom1.onMount = () => { + ++metrics1.mounted; + return () => { + ++metrics1.unmounted; + }; + }; + + const metrics2 = { + mounted: 0, + unmounted: 0, + }; + const atom2 = atom(0); + atom2.onMount = () => { + ++metrics2.mounted; + return () => { + ++metrics2.unmounted; + }; + }; + + let resolve = () => {}; + const derivedAtom = atom(async (get) => { + get(atom1); + await new Promise((r) => (resolve = r)); + get(atom2); + }); + + const unrelatedAtom = atom(0); + + const store = createStore(); + store.sub(derivedAtom, () => {}); + resolve(); + await Promise.resolve(); + await Promise.resolve(); // we need two awaits to reproduce + store.set(unrelatedAtom, (c) => c + 1); + expect(metrics1).toEqual({ + mounted: 1, + unmounted: 0, + }); + expect(metrics2).toEqual({ + mounted: 1, + unmounted: 0, + }); + store.set(atom1, (c) => c + 1); + resolve(); + expect(metrics1).toEqual({ + mounted: 1, + unmounted: 0, + }); + expect(metrics2).toEqual({ + mounted: 1, + unmounted: 0, + }); +}); + +it('should not provide stale values to conditional dependents', () => { + const dataAtom = atom([100]); + const hasFilterAtom = atom(false); + const filteredAtom = atom((get) => { + const data = get(dataAtom); + const hasFilter = get(hasFilterAtom); + if (hasFilter) { + return []; + } else { + return data; + } + }); + const stageAtom = atom((get) => { + const hasFilter = get(hasFilterAtom); + if (hasFilter) { + const filtered = get(filteredAtom); + return filtered.length === 0 ? 'is-empty' : 'has-data'; + } else { + return 'no-filter'; + } + }); + + const store = createStore(); + store.sub(filteredAtom, () => undefined); + store.sub(stageAtom, () => undefined); + + expect(store.get(stageAtom)).toBe('no-filter'); + store.set(hasFilterAtom, true); + expect(store.get(stageAtom)).toBe('is-empty'); +}); + +it('settles never resolving async derivations with deps picked up sync', async () => { + const resolve: ((value: number) => void)[] = []; + + const syncAtom = atom({ + promise: new Promise((r) => resolve.push(r)), + }); + + const asyncAtom = atom(async (get) => { + return await get(syncAtom).promise; + }); + + const store = createStore(); + + let sub = 0; + const values: unknown[] = []; + store.get(asyncAtom).then((value) => values.push(value)); + store.sub(asyncAtom, () => { + sub++; + store.get(asyncAtom).then((value) => values.push(value)); + }); + + store.set(syncAtom, { + promise: new Promise((r) => resolve.push(r)), + }); + resolve[1]?.(1); + + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(values).toEqual([1]); + expect(sub).toBe(1); +}); + +it('settles never resolving async derivations with deps picked up async', async () => { + const resolve: ((value: number) => void)[] = []; + + const syncAtom = atom({ + promise: new Promise((r) => resolve.push(r)), + }); + + const asyncAtom = atom(async (get) => { + // we want to pick up `syncAtom` as an async dep + await Promise.resolve(); + return await get(syncAtom).promise; + }); + + const store = createStore(); + + let sub = 0; + const values: unknown[] = []; + store.get(asyncAtom).then((value) => values.push(value)); + store.sub(asyncAtom, () => { + sub++; + store.get(asyncAtom).then((value) => values.push(value)); + }); + + await new Promise((r) => setTimeout(r)); // wait for a tick + store.set(syncAtom, { + promise: new Promise((r) => resolve.push(r)), + }); + resolve[1]?.(1); + + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(values).toEqual([1]); + expect(sub).toBe(1); +}); + +it('refreshes deps for each async read', async () => { + const countAtom = atom(0); + const depAtom = atom(false); + const resolve: (() => void)[] = []; + const values: number[] = []; + const asyncAtom = atom(async (get) => { + const count = get(countAtom); + values.push(count); + if (count === 0) { + get(depAtom); + } + await new Promise((r) => resolve.push(r)); + return count; + }); + const store = createStore(); + store.get(asyncAtom); + store.set(countAtom, (c) => c + 1); + resolve.splice(0).forEach((fn) => fn()); + expect(await store.get(asyncAtom)).toBe(1); + store.set(depAtom, true); + store.get(asyncAtom); + resolve.splice(0).forEach((fn) => fn()); + expect(values).toEqual([0, 1]); +}); diff --git a/__tests__/derive/baseTests/vanilla/store.test.tsx b/__tests__/derive/baseTests/vanilla/store.test.tsx new file mode 100644 index 0000000..d7f9b2f --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/store.test.tsx @@ -0,0 +1,582 @@ +import { waitFor } from '@testing-library/dom'; +import { atom } from '../../../../jotai/vanilla'; +import type { Getter } from '../../../../jotai/vanilla'; +import { createStore } from '../../derivedStore'; +import assert from 'minimalistic-assert'; + +it('should not fire on subscribe', async () => { + const store = createStore(); + const countAtom = atom(0); + const callback1 = jest.fn(); + const callback2 = jest.fn(); + store.sub(countAtom, callback1); + store.sub(countAtom, callback2); + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); +}); + +it('should not fire subscription if primitive atom value is the same', async () => { + const store = createStore(); + const countAtom = atom(0); + const callback = jest.fn(); + store.sub(countAtom, callback); + const calledTimes = callback.mock.calls.length; + store.set(countAtom, 0); + expect(callback).toHaveBeenCalledTimes(calledTimes); +}); + +it('should not fire subscription if derived atom value is the same', async () => { + const store = createStore(); + const countAtom = atom(0); + const derivedAtom = atom((get) => get(countAtom) * 0); + const callback = jest.fn(); + store.sub(derivedAtom, callback); + const calledTimes = callback.mock.calls.length; + store.set(countAtom, 1); + expect(callback).toHaveBeenCalledTimes(calledTimes); +}); + +it('should unmount with store.get', async () => { + const store = createStore(); + const countAtom = atom(0); + const callback = jest.fn(); + const unsub = store.sub(countAtom, callback); + store.get(countAtom); + unsub(); + const result = Array.from( + 'dev4_get_mounted_atoms' in store ? store.dev4_get_mounted_atoms() : [], + ); + expect(result).toEqual([]); +}); + +it('should unmount dependencies with store.get', async () => { + const store = createStore(); + const countAtom = atom(0); + const derivedAtom = atom((get) => get(countAtom) * 2); + const callback = jest.fn(); + const unsub = store.sub(derivedAtom, callback); + store.get(derivedAtom); + unsub(); + const result = Array.from( + 'dev4_restore_atoms' in store ? store.dev4_get_mounted_atoms() : [], + ); + expect(result).toEqual([]); +}); + +it('should update async atom with delay (#1813)', async () => { + const countAtom = atom(0); + + const resolve: (() => void)[] = []; + const delayedAtom = atom(async (get) => { + const count = get(countAtom); + await new Promise((r) => resolve.push(r)); + return count; + }); + + const store = createStore(); + store.get(delayedAtom); + store.set(countAtom, 1); + resolve.splice(0).forEach((fn) => fn()); + await new Promise((r) => setTimeout(r)); // wait for a tick + const promise = store.get(delayedAtom); + resolve.splice(0).forEach((fn) => fn()); + expect(await promise).toBe(1); +}); + +it('should override a promise by setting', async () => { + const store = createStore(); + const countAtom = atom(Promise.resolve(0)); + const infinitePending = new Promise(() => {}); + store.set(countAtom, infinitePending); + const promise1 = store.get(countAtom); + expect(promise1).toBe(infinitePending); + store.set(countAtom, Promise.resolve(1)); + const promise2 = store.get(countAtom); + expect(await promise2).toBe(1); +}); + +it('should update async atom with deps after await (#1905)', async () => { + const countAtom = atom(0); + const resolve: (() => void)[] = []; + const delayedAtom = atom(async (get) => { + await new Promise((r) => resolve.push(r)); + const count = get(countAtom); + return count; + }); + const derivedAtom = atom(async (get) => { + const count = await get(delayedAtom); + return count; + }); + + const store = createStore(); + let lastValue = store.get(derivedAtom); + const unsub = store.sub(derivedAtom, () => { + lastValue = store.get(derivedAtom); + }); + store.set(countAtom, 1); + resolve.splice(0).forEach((fn) => fn()); + expect(await lastValue).toBe(1); + store.set(countAtom, 2); + resolve.splice(0).forEach((fn) => fn()); + expect(await lastValue).toBe(2); + store.set(countAtom, 3); + resolve.splice(0).forEach((fn) => fn()); + expect(await lastValue).toBe(3); + unsub(); +}); + +it('should not fire subscription when async atom promise is the same', async () => { + const promise = Promise.resolve(); + const promiseAtom = atom(promise); + const derivedGetter = jest.fn((get: Getter) => get(promiseAtom)); + const derivedAtom = atom(derivedGetter); + + const store = createStore(); + + expect(derivedGetter).not.toHaveBeenCalled(); + + const promiseListener = jest.fn(); + const promiseUnsub = store.sub(promiseAtom, promiseListener); + const derivedListener = jest.fn(); + const derivedUnsub = store.sub(derivedAtom, derivedListener); + + expect(derivedGetter).toHaveBeenCalledTimes(1); + expect(promiseListener).not.toHaveBeenCalled(); + expect(derivedListener).not.toHaveBeenCalled(); + + store.get(promiseAtom); + store.get(derivedAtom); + + expect(derivedGetter).toHaveBeenCalledTimes(1); + expect(promiseListener).not.toHaveBeenCalled(); + expect(derivedListener).not.toHaveBeenCalled(); + + store.set(promiseAtom, promise); + + expect(derivedGetter).toHaveBeenCalledTimes(1); + expect(promiseListener).not.toHaveBeenCalled(); + expect(derivedListener).not.toHaveBeenCalled(); + + store.set(promiseAtom, promise); + + expect(derivedGetter).toHaveBeenCalledTimes(1); + expect(promiseListener).not.toHaveBeenCalled(); + expect(derivedListener).not.toHaveBeenCalled(); + + promiseUnsub(); + derivedUnsub(); +}); + +it('should notify subscription with tree dependencies (#1956)', async () => { + const valueAtom = atom(1); + const dep1Atom = atom((get) => get(valueAtom) * 2); + const dep2Atom = atom((get) => get(valueAtom) + get(dep1Atom)); + const dep3Atom = atom((get) => get(dep1Atom)); + + const cb = jest.fn(); + const store = createStore(); + store.sub(dep2Atom, jest.fn()); // this will cause the bug + store.sub(dep3Atom, cb); + + expect(cb).toBeCalledTimes(0); + expect(store.get(dep3Atom)).toBe(2); + store.set(valueAtom, (c) => c + 1); + expect(cb).toBeCalledTimes(1); + expect(store.get(dep3Atom)).toBe(4); +}); + +it('should notify subscription with tree dependencies with bail-out', async () => { + const valueAtom = atom(1); + const dep1Atom = atom((get) => get(valueAtom) * 2); + const dep2Atom = atom((get) => get(valueAtom) * 0); + const dep3Atom = atom((get) => get(dep1Atom) + get(dep2Atom)); + + const cb = jest.fn(); + const store = createStore(); + store.sub(dep1Atom, jest.fn()); + store.sub(dep3Atom, cb); + + expect(cb).toBeCalledTimes(0); + expect(store.get(dep3Atom)).toBe(2); + store.set(valueAtom, (c) => c + 1); + expect(cb).toBeCalledTimes(1); + expect(store.get(dep3Atom)).toBe(4); +}); + +it('should bail out with the same value with chained dependency (#2014)', async () => { + const store = createStore(); + const objAtom = atom({ count: 1 }); + const countAtom = atom((get) => get(objAtom).count); + const deriveFn = jest.fn((get: Getter) => get(countAtom)); + const derivedAtom = atom(deriveFn); + const deriveFurtherFn = jest.fn((get: Getter) => { + get(objAtom); // intentional extra dependency + return get(derivedAtom); + }); + const derivedFurtherAtom = atom(deriveFurtherFn); + const callback = jest.fn(); + store.sub(derivedFurtherAtom, callback); + expect(store.get(derivedAtom)).toBe(1); + expect(store.get(derivedFurtherAtom)).toBe(1); + expect(callback).toHaveBeenCalledTimes(0); + expect(deriveFn).toHaveBeenCalledTimes(1); + expect(deriveFurtherFn).toHaveBeenCalledTimes(1); + store.set(objAtom, (obj) => ({ ...obj })); + expect(callback).toHaveBeenCalledTimes(0); + expect(deriveFn).toHaveBeenCalledTimes(1); + expect(deriveFurtherFn).toHaveBeenCalledTimes(2); +}); + +it('should not call read function for unmounted atoms (#2076)', async () => { + const store = createStore(); + const countAtom = atom(1); + const derive1Fn = jest.fn((get: Getter) => get(countAtom)); + const derived1Atom = atom(derive1Fn); + const derive2Fn = jest.fn((get: Getter) => get(countAtom)); + const derived2Atom = atom(derive2Fn); + expect(store.get(derived1Atom)).toBe(1); + expect(store.get(derived2Atom)).toBe(1); + expect(derive1Fn).toHaveBeenCalledTimes(1); + expect(derive2Fn).toHaveBeenCalledTimes(1); + store.sub(derived2Atom, jest.fn()); + store.set(countAtom, (c) => c + 1); + expect(derive1Fn).toHaveBeenCalledTimes(1); + expect(derive2Fn).toHaveBeenCalledTimes(2); +}); + +it('should update with conditional dependencies (#2084)', async () => { + const store = createStore(); + const f1 = atom(false); + const f2 = atom(false); + const f3 = atom( + (get) => get(f1) && get(f2), + (_get, set, val: boolean) => { + set(f1, val); + set(f2, val); + }, + ); + store.sub(f1, jest.fn()); + store.sub(f2, jest.fn()); + store.sub(f3, jest.fn()); + store.set(f3, true); + expect(store.get(f3)).toBe(true); +}); + +it("should recompute dependents' state after onMount (#2098)", async () => { + const store = createStore(); + + const condAtom = atom(false); + const baseAtom = atom(false); + baseAtom.onMount = (set) => set(true); + const derivedAtom = atom( + (get) => get(baseAtom), + (_get, set, update: boolean) => set(baseAtom, update), + ); + const finalAtom = atom( + (get) => (get(condAtom) ? get(derivedAtom) : undefined), + (_get, set, value: boolean) => set(derivedAtom, value), + ); + + store.sub(finalAtom, () => {}); // mounts finalAtom, but not baseAtom + expect(store.get(baseAtom)).toBe(false); + expect(store.get(derivedAtom)).toBe(false); + expect(store.get(finalAtom)).toBe(undefined); + + store.set(condAtom, true); // mounts baseAtom + expect(store.get(baseAtom)).toBe(true); + expect(store.get(derivedAtom)).toBe(true); + expect(store.get(finalAtom)).toBe(true); + + store.set(finalAtom, false); + expect(store.get(baseAtom)).toBe(false); + expect(store.get(derivedAtom)).toBe(false); + expect(store.get(finalAtom)).toBe(false); +}); + +it('should update derived atoms during write (#2107)', async () => { + const store = createStore(); + + const baseCountAtom = atom(1); + const countAtom = atom( + (get) => get(baseCountAtom), + (get, set, newValue: number) => { + set(baseCountAtom, newValue); + if (get(countAtom) !== newValue) { + throw new Error('mismatch'); + } + }, + ); + + store.sub(countAtom, () => {}); + expect(store.get(countAtom)).toBe(1); + store.set(countAtom, 2); + expect(store.get(countAtom)).toBe(2); +}); + +it('resolves dependencies reliably after a delay (#2192)', async () => { + expect.assertions(1); + const countAtom = atom(0); + let result: number | null = null; + + const resolve: (() => void)[] = []; + const asyncAtom = atom(async (get) => { + const count = get(countAtom); + await new Promise((r) => resolve.push(r)); + return count; + }); + + const derivedAtom = atom( + async (get, { setSelf }) => { + get(countAtom); + await Promise.resolve(); + result = await get(asyncAtom); + if (result === 2) setSelf(); // <-- necessary + }, + () => {}, + ); + + const store = createStore(); + store.sub(derivedAtom, () => {}); + + await waitFor(() => assert(resolve.length === 1)); + + resolve[0]!(); + const increment = (c: number) => c + 1; + store.set(countAtom, increment); + store.set(countAtom, increment); + + await waitFor(() => assert(resolve.length === 3)); + + resolve[1]!(); + resolve[2]!(); + await waitFor(() => assert(result === 2)); + + store.set(countAtom, increment); + store.set(countAtom, increment); + + await waitFor(() => assert(resolve.length === 5)); + + resolve[3]!(); + resolve[4]!(); + + await new Promise((r) => setTimeout(r)); + await waitFor(() => assert(store.get(countAtom) === 4)); + + expect(result).toBe(4); // 3 +}); + +it('should not recompute a derived atom value if unchanged (#2168)', async () => { + const store = createStore(); + const countAtom = atom(1); + const derived1Atom = atom((get) => get(countAtom) * 0); + const derive2Fn = jest.fn((get: Getter) => get(derived1Atom)); + const derived2Atom = atom(derive2Fn); + expect(store.get(derived2Atom)).toBe(0); + store.set(countAtom, (c) => c + 1); + expect(store.get(derived2Atom)).toBe(0); + expect(derive2Fn).toHaveBeenCalledTimes(1); +}); + +it('should mount once with atom creator atom (#2314)', async () => { + const countAtom = atom(1); + countAtom.onMount = jest.fn((setAtom: (v: number) => void) => { + setAtom(2); + }); + const atomCreatorAtom = atom((get) => { + const derivedAtom = atom((get) => get(countAtom)); + get(derivedAtom); + }); + const store = createStore(); + store.sub(atomCreatorAtom, () => {}); + expect(countAtom.onMount).toHaveBeenCalledTimes(1); +}); + +it('should flush pending write triggered asynchronously and indirectly (#2451)', async () => { + const store = createStore(); + const anAtom = atom('initial'); + + const callbackFn = jest.fn((_value: string) => {}); + const unsub = store.sub(anAtom, () => { + callbackFn(store.get(anAtom)); + }); + + const actionAtom = atom(null, async (_get, set) => { + await Promise.resolve(); // waiting a microtask + set(indirectSetAtom); + }); + + const indirectSetAtom = atom(null, (_get, set) => { + set(anAtom, 'next'); + }); + + // executing the chain reaction + await store.set(actionAtom); + + expect(callbackFn).toHaveBeenCalledTimes(1); + expect(callbackFn).toHaveBeenCalledWith('next'); + unsub(); +}); + +describe('async atom with subtle timing', () => { + it('case 1', async () => { + const store = createStore(); + const resolve: (() => void)[] = []; + const a = atom(1); + const b = atom(async (get) => { + await new Promise((r) => resolve.push(r)); + return get(a); + }); + const bValue = store.get(b); + store.set(a, 2); + resolve.splice(0).forEach((fn) => fn()); + const bValue2 = store.get(b); + resolve.splice(0).forEach((fn) => fn()); + expect(await bValue).toBe(2); + expect(await bValue2).toBe(2); + }); + + it('case 2', async () => { + const store = createStore(); + const resolve: (() => void)[] = []; + const a = atom(1); + const b = atom(async (get) => { + const aValue = get(a); + await new Promise((r) => resolve.push(r)); + return aValue; + }); + const bValue = store.get(b); + store.set(a, 2); + resolve.splice(0).forEach((fn) => fn()); + const bValue2 = store.get(b); + resolve.splice(0).forEach((fn) => fn()); + expect(await bValue).toBe(1); // returns old value + expect(await bValue2).toBe(2); + }); +}); + +describe('aborting atoms', () => { + // We can't use signal.throwIfAborted as it is not available + // in earlier versions of TS that this is tested on. + const throwIfAborted = (signal: AbortSignal) => { + if (signal.aborted) { + throw new Error('aborted'); + } + }; + + it('should abort the signal when dependencies change', async () => { + const a = atom(1); + const callBeforeAbort = jest.fn(); + const callAfterAbort = jest.fn(); + const resolve: (() => void)[] = []; + + const store = createStore(); + + const derivedAtom = atom(async (get, { signal }) => { + const aVal = get(a); + await new Promise((r) => resolve.push(r)); + callBeforeAbort(); + throwIfAborted(signal); + callAfterAbort(); + return aVal + 1; + }); + + const promise = store.get(derivedAtom); + store.set(a, 3); + const promise2 = store.get(derivedAtom); + + resolve.splice(0).forEach((fn) => fn()); + expect(promise).rejects.toThrow('aborted'); + expect(await promise2).toEqual(4); + expect(callBeforeAbort).toHaveBeenCalledTimes(2); + expect(callAfterAbort).toHaveBeenCalledTimes(1); + }); + + it('should abort the signal when dependencies change and the atom is mounted', async () => { + const a = atom(1); + const callBeforeAbort = jest.fn(); + const callAfterAbort = jest.fn(); + const resolve: (() => void)[] = []; + + const store = createStore(); + + const derivedAtom = atom(async (get, { signal }) => { + const aVal = get(a); + await new Promise((r) => resolve.push(r)); + callBeforeAbort(); + throwIfAborted(signal); + callAfterAbort(); + return aVal + 1; + }); + + store.sub(derivedAtom, () => {}); + store.set(a, 3); + + resolve.splice(0).forEach((fn) => fn()); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(callBeforeAbort).toHaveBeenCalledTimes(2); + expect(callAfterAbort).toHaveBeenCalledTimes(1); + }); + + it('should not abort the signal when unsubscribed', async () => { + const a = atom(1); + const callBeforeAbort = jest.fn(); + const callAfterAbort = jest.fn(); + const resolve: (() => void)[] = []; + + const store = createStore(); + + const derivedAtom = atom(async (get, { signal }) => { + const aVal = get(a); + await new Promise((r) => resolve.push(r)); + callBeforeAbort(); + throwIfAborted(signal); + callAfterAbort(); + return aVal + 1; + }); + + const unsub = store.sub(derivedAtom, () => {}); + unsub(); + resolve.splice(0).forEach((fn) => fn()); + + expect(await store.get(derivedAtom)).toEqual(2); + expect(callBeforeAbort).toHaveBeenCalledTimes(1); + expect(callAfterAbort).toHaveBeenCalledTimes(1); + }); +}); + +it('Unmount an atom that is no longer dependent within a derived atom (#2658)', async () => { + const condAtom = atom(true); + + const baseAtom = atom(0); + const onUnmount = jest.fn(); + baseAtom.onMount = () => onUnmount; + + const derivedAtom = atom((get) => { + if (get(condAtom)) get(baseAtom); + }); + + const store = createStore(); + store.sub(derivedAtom, () => {}); + store.set(condAtom, false); + expect(onUnmount).toHaveBeenCalledTimes(1); +}); + +it('should update derived atom even if dependances changed (#2697)', () => { + const primitiveAtom = atom(undefined); + const derivedAtom = atom((get) => get(primitiveAtom)); + const conditionalAtom = atom((get) => { + const base = get(primitiveAtom); + if (!base) return; + return get(derivedAtom); + }); + + const store = createStore(); + const onChangeDerived = jest.fn(); + + store.sub(derivedAtom, onChangeDerived); + store.sub(conditionalAtom, () => {}); + + expect(onChangeDerived).toHaveBeenCalledTimes(0); + store.set(primitiveAtom, 1); + expect(onChangeDerived).toHaveBeenCalledTimes(1); +}); diff --git a/__tests__/derive/baseTests/vanilla/storedev.test.tsx b/__tests__/derive/baseTests/vanilla/storedev.test.tsx new file mode 100644 index 0000000..cd82bc0 --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/storedev.test.tsx @@ -0,0 +1,99 @@ +import { atom } from '../../../../jotai/vanilla'; +import type { + INTERNAL_DevStoreRev4, + INTERNAL_PrdStore, +} from '../../../../jotai/vanilla/store'; +import { createStore } from '../../derivedStore'; + +describe('[DEV-ONLY] dev-only methods rev4', () => { + it('should get atom value', () => { + const store = createStore() as any; + if (!('dev4_get_internal_weak_map' in store)) { + throw new Error('dev methods are not available'); + } + const countAtom = atom(0); + countAtom.debugLabel = 'countAtom'; + store.set(countAtom, 1); + const weakMap = store.dev4_get_internal_weak_map(); + expect(weakMap.get(countAtom)?.v).toEqual(1); + }); + + it('should restore atoms and its dependencies correctly', () => { + const store = createStore() as any; + if (!('dev4_restore_atoms' in store)) { + throw new Error('dev methods are not available'); + } + const countAtom = atom(0); + const derivedAtom = atom((get) => get(countAtom) * 2); + store.set(countAtom, 1); + store.dev4_restore_atoms([[countAtom, 2]]); + expect(store.get(countAtom)).toBe(2); + expect(store.get?.(derivedAtom)).toBe(4); + }); + + it('should restore atoms and call store listeners correctly', () => { + const store = createStore() as any; + if (!('dev4_restore_atoms' in store)) { + throw new Error('dev methods are not available'); + } + const countAtom = atom(0); + const derivedAtom = atom((get) => get(countAtom) * 2); + const countCb = jest.fn(); + const derivedCb = jest.fn(); + store.set(countAtom, 2); + const unsubCount = store.sub(countAtom, countCb); + const unsubDerived = store.sub(derivedAtom, derivedCb); + store.dev4_restore_atoms([ + [countAtom, 1], + [derivedAtom, 2], + ]); + + expect(countCb).toHaveBeenCalled(); + expect(derivedCb).toHaveBeenCalled(); + unsubCount(); + unsubDerived(); + }); + + it('should return all the mounted atoms correctly', () => { + const store = createStore() as INTERNAL_DevStoreRev4 & INTERNAL_PrdStore; + if (!('dev4_get_mounted_atoms' in store)) { + throw new Error('dev methods are not available'); + } + const countAtom = atom(0); + countAtom.debugLabel = 'countAtom'; + const derivedAtom = atom((get) => get(countAtom) * 2); + const unsub = store.sub(derivedAtom, jest.fn()); + store.set(countAtom, 1); + const result = store.dev4_get_mounted_atoms(); + expect( + Array.from(result).sort( + (a, b) => Object.keys(a).length - Object.keys(b).length, + ), + ).toStrictEqual([ + { toString: expect.any(Function), read: expect.any(Function) }, + { + toString: expect.any(Function), + init: 0, + read: expect.any(Function), + write: expect.any(Function), + debugLabel: 'countAtom', + }, + ]); + unsub(); + }); + + it("should return all the mounted atoms correctly after they're unsubscribed", () => { + const store = createStore() as INTERNAL_DevStoreRev4 & INTERNAL_PrdStore; + if (!('dev4_get_mounted_atoms' in store)) { + throw new Error('dev methods are not available'); + } + const countAtom = atom(0); + countAtom.debugLabel = 'countAtom'; + const derivedAtom = atom((get) => get(countAtom) * 2); + const unsub = store.sub(derivedAtom, jest.fn()); + store.set(countAtom, 1); + unsub(); + const result = store.dev4_get_mounted_atoms(); + expect(Array.from(result)).toStrictEqual([]); + }); +}); diff --git a/__tests__/derive/baseTests/vanilla/unstable_derive.test.tsx b/__tests__/derive/baseTests/vanilla/unstable_derive.test.tsx new file mode 100644 index 0000000..02414ee --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/unstable_derive.test.tsx @@ -0,0 +1,304 @@ +import { atom } from '../../../../jotai/vanilla'; +import type { Atom } from '../../../../jotai/vanilla'; +import { createStore } from '../../derivedStore'; + +describe('unstable_derive for scoping atoms', () => { + /** + * a + * S1[a]: a1 + */ + it('primitive atom', async () => { + const a = atom('a'); + a.onMount = (setSelf) => setSelf((v) => v + ':mounted'); + const scopedAtoms = new Set>([a]); + + const store = createStore(); + const derivedStore = store.unstable_derive((getAtomState) => { + const scopedAtomStateMap = new WeakMap(); + return [ + (atom, originAtomState) => { + if (scopedAtoms.has(atom)) { + let atomState = scopedAtomStateMap.get(atom); + if (!atomState) { + atomState = { d: new Map(), p: new Set(), n: 0 }; + scopedAtomStateMap.set(atom, atomState); + } + return atomState; + } + return getAtomState(atom, originAtomState); + }, + ]; + }); + + expect(store.get(a)).toBe('a'); + expect(derivedStore.get(a)).toBe('a'); + + derivedStore.sub(a, jest.fn()); + await new Promise((resolve) => setTimeout(resolve)); + expect(store.get(a)).toBe('a'); + expect(derivedStore.get(a)).toBe('a:mounted'); + + derivedStore.set(a, (v) => v + ':updated'); + await new Promise((resolve) => setTimeout(resolve)); + expect(store.get(a)).toBe('a'); + expect(derivedStore.get(a)).toBe('a:mounted:updated'); + }); + + /** + * a, b, c(a + b) + * S1[a]: a1, b0, c0(a1 + b0) + */ + it('derived atom (scoping primitive)', async () => { + const a = atom('a'); + const b = atom('b'); + const c = atom((get) => get(a) + get(b)); + const scopedAtoms = new Set>([a]); + + const store = createStore(); + const derivedStore = store.unstable_derive((getAtomState) => { + const scopedAtomStateMap = new WeakMap(); + return [ + (atom, originAtomState) => { + if (scopedAtoms.has(atom)) { + let atomState = scopedAtomStateMap.get(atom); + if (!atomState) { + atomState = { d: new Map(), p: new Set(), n: 0 }; + scopedAtomStateMap.set(atom, atomState); + } + return atomState; + } + return getAtomState(atom, originAtomState); + }, + ]; + }); + + expect(store.get(c)).toBe('ab'); + expect(derivedStore.get(c)).toBe('ab'); + + derivedStore.set(a, 'a2'); + await new Promise((resolve) => setTimeout(resolve)); + expect(store.get(c)).toBe('ab'); + expect(derivedStore.get(c)).toBe('a2b'); + }); + + /** + * a, b(a) + * S1[b]: a0, b1(a1) + */ + it('derived atom (scoping derived)', async () => { + const a = atom('a'); + const b = atom( + (get) => get(a), + (_get, set, v: string) => { + set(a, v); + }, + ); + const scopedAtoms = new Set>([b]); + + const store = createStore(); + const derivedStore = store.unstable_derive((getAtomState) => { + const scopedAtomStateMap = new WeakMap(); + const scopedAtomStateSet = new WeakSet(); + return [ + (atom, originAtomState) => { + if ( + scopedAtomStateSet.has(originAtomState as never) || + scopedAtoms.has(atom) + ) { + let atomState = scopedAtomStateMap.get(atom); + if (!atomState) { + atomState = { d: new Map(), p: new Set(), n: 0 }; + scopedAtomStateMap.set(atom, atomState); + scopedAtomStateSet.add(atomState); + } + return atomState; + } + return getAtomState(atom, originAtomState); + }, + ]; + }); + + expect(store.get(a)).toBe('a'); + expect(store.get(b)).toBe('a'); + expect(derivedStore.get(a)).toBe('a'); + expect(derivedStore.get(b)).toBe('a'); + + store.set(a, 'a2'); + await new Promise((resolve) => setTimeout(resolve)); + expect(store.get(a)).toBe('a2'); + expect(store.get(b)).toBe('a2'); + expect(derivedStore.get(a)).toBe('a2'); + expect(derivedStore.get(b)).toBe('a'); + + store.set(b, 'a3'); + await new Promise((resolve) => setTimeout(resolve)); + expect(store.get(a)).toBe('a3'); + expect(store.get(b)).toBe('a3'); + expect(derivedStore.get(a)).toBe('a3'); + expect(derivedStore.get(b)).toBe('a'); + + derivedStore.set(a, 'a4'); + await new Promise((resolve) => setTimeout(resolve)); + expect(store.get(a)).toBe('a4'); + expect(store.get(b)).toBe('a4'); + expect(derivedStore.get(a)).toBe('a4'); + expect(derivedStore.get(b)).toBe('a'); + + derivedStore.set(b, 'a5'); + await new Promise((resolve) => setTimeout(resolve)); + expect(store.get(a)).toBe('a4'); + expect(store.get(b)).toBe('a4'); + expect(derivedStore.get(a)).toBe('a4'); + expect(derivedStore.get(b)).toBe('a5'); + }); + + /** + * a, b, c(a), d(c), e(d + b) + * S1[d]: a0, b0, c0(a0), d1(c1(a1)), e0(d1(c1(a1)) + b0) + */ + it('derived atom (scoping derived chain)', async () => { + const a = atom('a'); + const b = atom('b'); + const c = atom( + (get) => get(a), + (_get, set, v: string) => set(a, v), + ); + const d = atom( + (get) => get(c), + (_get, set, v: string) => set(c, v), + ); + const e = atom( + (get) => get(d) + get(b), + (_get, set, av: string, bv: string) => { + set(d, av); + set(b, bv); + }, + ); + const scopedAtoms = new Set>([d]); + + function makeStores() { + const baseStore = createStore(); + const deriStore = baseStore.unstable_derive((getAtomState) => { + const scopedAtomStateMap = new WeakMap(); + const scopedAtomStateSet = new WeakSet(); + return [ + (atom, originAtomState) => { + if ( + scopedAtomStateSet.has(originAtomState as never) || + scopedAtoms.has(atom) + ) { + let atomState = scopedAtomStateMap.get(atom); + if (!atomState) { + atomState = { d: new Map(), p: new Set(), n: 0 }; + scopedAtomStateMap.set(atom, atomState); + scopedAtomStateSet.add(atomState); + } + return atomState; + } + return getAtomState(atom, originAtomState); + }, + ]; + }); + expect(getAtoms(baseStore)).toEqual(['a', 'b', 'a', 'a', 'ab']); + expect(getAtoms(deriStore)).toEqual(['a', 'b', 'a', 'a', 'ab']); + return { baseStore, deriStore }; + } + type Store = ReturnType; + function getAtoms(store: Store) { + return [ + store.get(a), + store.get(b), + store.get(c), + store.get(d), + store.get(e), + ]; + } + + /** + * base[d]: a0, b0, c0(a0), d0(c0(a0)), e0(d0(c0(a0)) + b0) + * deri[d]: a0, b0, c0(a0), d1(c1(a1)), e0(d1(c1(a1)) + b0) + */ + { + // UPDATE a0 + // NOCHGE b0 and a1 + const { baseStore, deriStore } = makeStores(); + baseStore.set(a, '*'); + expect(getAtoms(baseStore)).toEqual(['*', 'b', '*', '*', '*b']); + expect(getAtoms(deriStore)).toEqual(['*', 'b', '*', 'a', 'ab']); + } + { + // UPDATE b0 + // NOCHGE a0 and a1 + const { baseStore, deriStore } = makeStores(); + baseStore.set(b, '*'); + expect(getAtoms(baseStore)).toEqual(['a', '*', 'a', 'a', 'a*']); + expect(getAtoms(deriStore)).toEqual(['a', '*', 'a', 'a', 'a*']); + } + { + // UPDATE c0, c0 -> a0 + // NOCHGE b0 and a1 + const { baseStore, deriStore } = makeStores(); + baseStore.set(c, '*'); + expect(getAtoms(baseStore)).toEqual(['*', 'b', '*', '*', '*b']); + expect(getAtoms(deriStore)).toEqual(['*', 'b', '*', 'a', 'ab']); + } + { + // UPDATE d0, d0 -> c0 -> a0 + // NOCHGE b0 and a1 + const { baseStore, deriStore } = makeStores(); + baseStore.set(d, '*'); + expect(getAtoms(baseStore)).toEqual(['*', 'b', '*', '*', '*b']); + expect(getAtoms(deriStore)).toEqual(['*', 'b', '*', 'a', 'ab']); + } + { + // UPDATE e0, e0 -> d0 -> c0 -> a0 + // └--------------> b0 + // NOCHGE a1 + const { baseStore, deriStore } = makeStores(); + baseStore.set(e, '*', '*'); + expect(getAtoms(baseStore)).toEqual(['*', '*', '*', '*', '**']); + expect(getAtoms(deriStore)).toEqual(['*', '*', '*', 'a', 'a*']); + } + { + // UPDATE a0 + // NOCHGE b0 and a1 + const { baseStore, deriStore } = makeStores(); + deriStore.set(a, '*'); + expect(getAtoms(baseStore)).toEqual(['*', 'b', '*', '*', '*b']); + expect(getAtoms(deriStore)).toEqual(['*', 'b', '*', 'a', 'ab']); + } + { + // UPDATE b0 + // NOCHGE a0 and a1 + const { baseStore, deriStore } = makeStores(); + deriStore.set(b, '*'); + expect(getAtoms(baseStore)).toEqual(['a', '*', 'a', 'a', 'a*']); + expect(getAtoms(deriStore)).toEqual(['a', '*', 'a', 'a', 'a*']); + } + { + // UPDATE c0, c0 -> a0 + // NOCHGE b0 and a1 + const { baseStore, deriStore } = makeStores(); + deriStore.set(c, '*'); + expect(getAtoms(baseStore)).toEqual(['*', 'b', '*', '*', '*b']); + expect(getAtoms(deriStore)).toEqual(['*', 'b', '*', 'a', 'ab']); + } + { + // UPDATE d1, d1 -> c1 -> a1 + // NOCHGE b0 and a0 + const { baseStore, deriStore } = makeStores(); + deriStore.set(d, '*'); + expect(getAtoms(baseStore)).toEqual(['a', 'b', 'a', 'a', 'ab']); + expect(getAtoms(deriStore)).toEqual(['a', 'b', 'a', '*', '*b']); + } + { + // UPDATE e0, e0 -> d1 -> c1 -> a1 + // └--------------> b0 + // NOCHGE a0 + const { baseStore, deriStore } = makeStores(); + deriStore.set(e, '*', '*'); + expect(getAtoms(baseStore)).toEqual(['a', '*', 'a', 'a', 'a*']); + expect(getAtoms(deriStore)).toEqual(['a', '*', 'a', '*', '**']); + } + }); +}); diff --git a/__tests__/derive/baseTests/vanilla/utils/atomFamily.test.ts b/__tests__/derive/baseTests/vanilla/utils/atomFamily.test.ts new file mode 100644 index 0000000..59890e0 --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/utils/atomFamily.test.ts @@ -0,0 +1,95 @@ +import { atom } from '../../../../../jotai/vanilla'; +import type { Atom } from '../../../../../jotai/vanilla'; +import { atomFamily } from '../../../../../jotai/vanilla/utils'; +import { createStore } from '../../../derivedStore'; + +it('should create atoms with different params', () => { + const store = createStore(); + const aFamily = atomFamily((param: number) => atom(param)); + + expect(store.get(aFamily(1))).toEqual(1); + expect(store.get(aFamily(2))).toEqual(2); +}); + +it('should remove atoms', () => { + const store = createStore(); + const initializeAtom = jest.fn((param: number) => atom(param)); + const aFamily = atomFamily(initializeAtom); + + expect(store.get(aFamily(1))).toEqual(1); + expect(store.get(aFamily(2))).toEqual(2); + aFamily.remove(2); + initializeAtom.mockClear(); + expect(store.get(aFamily(1))).toEqual(1); + expect(initializeAtom).toHaveBeenCalledTimes(0); + expect(store.get(aFamily(2))).toEqual(2); + expect(initializeAtom).toHaveBeenCalledTimes(1); +}); + +it('should remove atoms with custom comparator', () => { + const store = createStore(); + const initializeAtom = jest.fn((param: number) => atom(param)); + const aFamily = atomFamily(initializeAtom, (a, b) => a === b); + + expect(store.get(aFamily(1))).toEqual(1); + expect(store.get(aFamily(2))).toEqual(2); + expect(store.get(aFamily(3))).toEqual(3); + aFamily.remove(2); + initializeAtom.mockClear(); + expect(store.get(aFamily(1))).toEqual(1); + expect(initializeAtom).toHaveBeenCalledTimes(0); + expect(store.get(aFamily(2))).toEqual(2); + expect(initializeAtom).toHaveBeenCalledTimes(1); +}); + +it('should remove atoms with custom shouldRemove', () => { + const store = createStore(); + const initializeAtom = jest.fn((param: number) => atom(param)); + const aFamily = atomFamily>(initializeAtom); + expect(store.get(aFamily(1))).toEqual(1); + expect(store.get(aFamily(2))).toEqual(2); + expect(store.get(aFamily(3))).toEqual(3); + aFamily.setShouldRemove((_createdAt, param) => param % 2 === 0); + initializeAtom.mockClear(); + expect(store.get(aFamily(1))).toEqual(1); + expect(initializeAtom).toHaveBeenCalledTimes(0); + expect(store.get(aFamily(2))).toEqual(2); + expect(initializeAtom).toHaveBeenCalledTimes(1); + expect(store.get(aFamily(3))).toEqual(3); + expect(initializeAtom).toHaveBeenCalledTimes(1); +}); + +it('should notify listeners', () => { + const aFamily = atomFamily((param: number) => atom(param)); + const listener = jest.fn(() => {}); + type Event = { type: 'CREATE' | 'REMOVE'; param: number; atom: Atom }; + const unsubscribe = aFamily.unstable_listen(listener); + const atom1 = aFamily(1); + expect(listener).toHaveBeenCalledTimes(1); + const eventCreate = listener.mock.calls[0]?.at(0) as unknown as Event; + if (!eventCreate) throw new Error('eventCreate is undefined'); + expect(eventCreate.type).toEqual('CREATE'); + expect(eventCreate.param).toEqual(1); + expect(eventCreate.atom).toEqual(atom1); + listener.mockClear(); + aFamily.remove(1); + expect(listener).toHaveBeenCalledTimes(1); + const eventRemove = listener.mock.calls[0]?.at(0) as unknown as Event; + expect(eventRemove.type).toEqual('REMOVE'); + expect(eventRemove.param).toEqual(1); + expect(eventRemove.atom).toEqual(atom1); + unsubscribe(); + listener.mockClear(); + aFamily(2); + expect(listener).toHaveBeenCalledTimes(0); +}); + +it('should return all params', () => { + const store = createStore(); + const aFamily = atomFamily((param: number) => atom(param)); + + expect(store.get(aFamily(1))).toEqual(1); + expect(store.get(aFamily(2))).toEqual(2); + expect(store.get(aFamily(3))).toEqual(3); + expect(Array.from(aFamily.getParams())).toEqual([1, 2, 3]); +}); diff --git a/__tests__/derive/baseTests/vanilla/utils/atomWithLazy.test.ts b/__tests__/derive/baseTests/vanilla/utils/atomWithLazy.test.ts new file mode 100644 index 0000000..59bcca3 --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/utils/atomWithLazy.test.ts @@ -0,0 +1,40 @@ +import { atomWithLazy } from '../../../../../jotai/vanilla/utils'; +import { createStore } from '../../../derivedStore'; + +it('initializes on first store get', async () => { + const storeA = createStore(); + const storeB = createStore(); + + let externalState = 'first'; + const initializer = jest.fn(() => externalState); + const anAtom = atomWithLazy(initializer); + + expect(initializer).not.toHaveBeenCalled(); + expect(storeA.get(anAtom)).toEqual('first'); + expect(initializer).toHaveBeenCalledTimes(1); + + externalState = 'second'; + + expect(storeA.get(anAtom)).toEqual('first'); + expect(initializer).toHaveBeenCalledTimes(1); + expect(storeB.get(anAtom)).toEqual('second'); + expect(initializer).toHaveBeenCalledTimes(2); +}); + +it('is writable', async () => { + const store = createStore(); + const anAtom = atomWithLazy(() => 0); + + store.set(anAtom, 123); + + expect(store.get(anAtom)).toEqual(123); +}); + +it('should work with a set state action', async () => { + const store = createStore(); + const anAtom = atomWithLazy(() => 4); + + store.set(anAtom, (prev: number) => prev * prev); + + expect(store.get(anAtom)).toEqual(16); +}); diff --git a/__tests__/derive/baseTests/vanilla/utils/loadable.test.ts b/__tests__/derive/baseTests/vanilla/utils/loadable.test.ts new file mode 100644 index 0000000..6afd0da --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/utils/loadable.test.ts @@ -0,0 +1,20 @@ +import { atom } from '../../../../../jotai/vanilla'; +import { loadable } from '../../../../../jotai/vanilla/utils'; +import { createStore } from '../../../derivedStore'; + +describe('loadable', () => { + it('should return fulfilled value of an already resolved async atom', async () => { + const store = createStore(); + const asyncAtom = atom(Promise.resolve('concrete')); + + expect(await store.get(asyncAtom)).toEqual('concrete'); + expect(store.get(loadable(asyncAtom))).toEqual({ + state: 'loading', + }); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(loadable(asyncAtom))).toEqual({ + state: 'hasData', + data: 'concrete', + }); + }); +}); diff --git a/__tests__/derive/baseTests/vanilla/utils/unwrap.test.ts b/__tests__/derive/baseTests/vanilla/utils/unwrap.test.ts new file mode 100644 index 0000000..06c83cf --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/utils/unwrap.test.ts @@ -0,0 +1,140 @@ +import { atom } from '../../../../../jotai/vanilla'; +import { unwrap } from '../../../../../jotai/vanilla/utils'; +import { createStore } from '../../../derivedStore'; + +describe('unwrap', () => { + it('should unwrap a promise with no fallback function', async () => { + const store = createStore(); + const countAtom = atom(1); + let resolve = () => {}; + const asyncAtom = atom(async (get) => { + const count = get(countAtom); + await new Promise((r) => (resolve = r)); + return count * 2; + }); + + const syncAtom = unwrap(asyncAtom); + + expect(store.get(syncAtom)).toBe(undefined); + resolve(); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(2); + + store.set(countAtom, 2); + expect(store.get(syncAtom)).toBe(undefined); + resolve(); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(4); + + store.set(countAtom, 3); + expect(store.get(syncAtom)).toBe(undefined); + resolve(); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(6); + }); + + it('should unwrap a promise with fallback function without prev', async () => { + const store = createStore(); + const countAtom = atom(1); + let resolve = () => {}; + const asyncAtom = atom(async (get) => { + const count = get(countAtom); + await new Promise((r) => (resolve = r)); + return count * 2; + }); + const syncAtom = unwrap(asyncAtom, () => -1); + expect(store.get(syncAtom)).toBe(-1); + resolve(); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(2); + store.set(countAtom, 2); + expect(store.get(syncAtom)).toBe(-1); + resolve(); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(4); + store.set(countAtom, 3); + expect(store.get(syncAtom)).toBe(-1); + resolve(); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(6); + }); + + it('should unwrap a promise with fallback function with prev', async () => { + const store = createStore(); + const countAtom = atom(1); + let resolve = () => {}; + const asyncAtom = atom(async (get) => { + const count = get(countAtom); + await new Promise((r) => (resolve = r)); + return count * 2; + }); + const syncAtom = unwrap(asyncAtom, (prev?: number) => prev ?? 0); + + expect(store.get(syncAtom)).toBe(0); + resolve(); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(2); + + store.set(countAtom, 2); + expect(store.get(syncAtom)).toBe(2); + resolve(); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(4); + + store.set(countAtom, 3); + expect(store.get(syncAtom)).toBe(4); + resolve(); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(6); + + store.set(countAtom, 4); + expect(store.get(syncAtom)).toBe(6); + resolve(); + store.set(countAtom, 5); + expect(store.get(syncAtom)).not.toBe(0); // expect 6 or 8 + resolve(); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(10); + }); + + it('should unwrap a sync atom which is noop', async () => { + const store = createStore(); + const countAtom = atom(1); + const syncAtom = unwrap(countAtom); + expect(store.get(syncAtom)).toBe(1); + store.set(countAtom, 2); + expect(store.get(syncAtom)).toBe(2); + store.set(countAtom, 3); + expect(store.get(syncAtom)).toBe(3); + }); + + it('should unwrap an async writable atom', async () => { + const store = createStore(); + const asyncAtom = atom(Promise.resolve(1)); + const syncAtom = unwrap(asyncAtom, (prev?: number) => prev ?? 0); + + expect(store.get(syncAtom)).toBe(0); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(1); + + store.set(syncAtom, Promise.resolve(2)); + expect(store.get(syncAtom)).toBe(1); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(2); + + store.set(syncAtom, Promise.resolve(3)); + expect(store.get(syncAtom)).toBe(2); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(syncAtom)).toBe(3); + }); + + it('should unwrap to a fulfilled value of an already resolved async atom', async () => { + const store = createStore(); + const asyncAtom = atom(Promise.resolve('concrete')); + + expect(await store.get(asyncAtom)).toEqual('concrete'); + expect(store.get(unwrap(asyncAtom))).toEqual(undefined); + await new Promise((r) => setTimeout(r)); // wait for a tick + expect(store.get(unwrap(asyncAtom))).toEqual('concrete'); + }); +}); diff --git a/__tests__/derive/derivedStore.ts b/__tests__/derive/derivedStore.ts new file mode 100644 index 0000000..2f38c4a --- /dev/null +++ b/__tests__/derive/derivedStore.ts @@ -0,0 +1,19 @@ +import { createStore as baseCreateStore } from '../../jotai'; + +export function createStore() { + const store = baseCreateStore(); + const derivedStore = store.unstable_derive((getAtomState) => { + return [ + (atom, originAtomState) => { + return getAtomState(atom, originAtomState); + }, + ]; + }); + return derivedStore; +} + +const store = createStore(); + +export function getDefaultStore() { + return store; +} diff --git a/__tests__/derive/types.ts b/__tests__/derive/types.ts new file mode 100644 index 0000000..28b26bc --- /dev/null +++ b/__tests__/derive/types.ts @@ -0,0 +1,63 @@ +import type { Atom, WritableAtom } from '../../jotai'; + +export type AnyValue = unknown; +export type AnyError = unknown; +export type AnyAtom = Atom; +export type AnyWritableAtom = WritableAtom; +export type OnUnmount = () => void; + +/** + * Mutable atom state, + * tracked for both mounted and unmounted atoms in a store. + */ +export type AtomState = { + /** + * Map of atoms that the atom depends on. + * The map value is the epoch number of the dependency. + */ + readonly d: Map; + /** + * Set of atoms with pending promise that depend on the atom. + * + * This may cause memory leaks, but it's for the capability to continue promises + */ + readonly p: Set; + /** The epoch number of the atom. */ + n: number; + /** + * Object to store mounted state of the atom. + * + * State tracked for mounted atoms. An atom is considered "mounted" if it has a + * subscriber, or is a transitive dependency of another atom that has a + * subscriber. + * + * The mounted state of an atom is freed once it is no longer mounted. + * + * only available if the atom is mounted + */ + m?: { + /** Set of listeners to notify when the atom value changes. */ + readonly l: Set<() => void>; + /** Set of mounted atoms that the atom depends on. */ + readonly d: Set; + /** Set of mounted atoms that depends on the atom. */ + readonly t: Set; + /** Function to run when the atom is unmounted. */ + u?: OnUnmount; + }; + /** Atom value */ + v?: Value; + /** Atom error */ + e?: AnyError; +}; + +export type GetAtomState = ( + atom: Atom, + originAtomState?: AtomState, +) => AtomState; + +// internal & unstable type +export type StoreArgs = readonly [ + getAtomState: GetAtomState, + // possible other arguments in the future +]; diff --git a/package.json b/package.json index 3258131..afd9db5 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,8 @@ "jest": { "testEnvironment": "jsdom", "preset": "ts-jest/presets/js-with-ts", - "testPathIgnorePatterns": [ - "utils.ts" + "testMatch": [ + "**/__tests__/**/*.test.*" ] }, "keywords": [