diff --git a/.eslintrc.json b/.eslintrc.json index e1af2c3..8cd799f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -25,7 +25,8 @@ "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/no-non-null-asserted-optional-chain": "off", + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], "react/button-has-type": "off", "react/jsx-filename-extension": ["error", { "extensions": [".js", ".tsx"] }], "react/prop-types": "off", diff --git a/__tests__/derive/baseTests/react/abortable.test.tsx b/__tests__/derive/baseTests/react/abortable.test.tsx new file mode 100644 index 0000000..417b540 --- /dev/null +++ b/__tests__/derive/baseTests/react/abortable.test.tsx @@ -0,0 +1,213 @@ +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 + }) + + function Component() { + const count = useAtomValue(derivedAtom) + return
count: {count}
+ } + + function 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 + }) + + function Component() { + const count = useAtomValue(derivedAtom) + return
count: {count}
+ } + + function 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 + }) + + function Component() { + const count = useAtomValue(derivedAtom) + return
count: {count}
+ } + + function 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 + }) + + function Component() { + const count = useAtomValue(derivedAtom) + return
count: {count}
+ } + + function 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..8122aec --- /dev/null +++ b/__tests__/derive/baseTests/react/async.test.tsx @@ -0,0 +1,1133 @@ +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 = () => {} + function 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}
+ + + ) + } + + function 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)) + + function DisplayAsyncValue() { + const [asyncValue] = useAtom(asyncAlwaysNullAtom) + + return
async value: {JSON.stringify(asyncValue)}
+ } + + function DisplayDerivedValue() { + const [derivedValue] = useAtom(derivedAtom) + return
derived value: {JSON.stringify(derivedValue)}
+ } + + function 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) + }) + + function Counter() { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + function 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' + }) + + function 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' + }) + + function 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)) + + function 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), + ) + + function Counter() { + const [count, incCount] = useAtom(asyncCountAtom) + useEffect(() => { + incCount() + }, [incCount]) + return
count: {count}
+ } + + function 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) + }, + ) + + function Counter() { + const [count, incCount] = useAtom(asyncCountAtom) + useEffect(() => { + incCount() + }, [incCount]) + return
count: {count}
+ } + + function 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' + }) + + function AsyncComponent() { + const [text] = useAtom(asyncAtom) + return
text: {text}
+ } + + function 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 + }) + + function AsyncComponent() { + const [count] = useAtom(asyncAtom) + return
async: {count}
+ } + + function 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)))), + ) + }) + + function Counter() { + const [count] = useAtom(countAtom) + return
count: {count * 1}
+ } + + function 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) + }) + + function 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 + }) + + function 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)))) + + function 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() + }) + + function 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) + }) + + function 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)) + }) + + function 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)) + }) + + function Counter() { + const [count] = useAtom(countAtom) + return
count: {count * 1}
+ } + + function 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() + } + + function Counter() { + const [count] = useAtom(derivedAtom) + return
count: {count}
+ } + + function 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)) + }) + + function Counter() { + const [count] = useAtom(derivedAtom) + return
count: {count}
+ } + + function 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) + }) + + function Counter() { + const [count] = useAtom(countAtom) + return
count: {count}
+ } + + function 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)) + + function AsyncComponent() { + const [text] = useAtom(anotherAsyncAtom) + return
async: {text}
+ } + + function 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) + }) + + function AsyncComponent() { + const [text] = useAtom(anotherAsyncAtom) + return
async: {text}
+ } + + function 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) + }) + + function DerivedCounter() { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + function 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) + + function 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..32f1e84 --- /dev/null +++ b/__tests__/derive/baseTests/react/async2.test.tsx @@ -0,0 +1,353 @@ +import { StrictMode, Suspense } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import assert from 'minimalistic-assert' +import { useAtom, useAtomValue, useSetAtom } from '../../../../jotai/react' +import { atom } from '../../../../jotai/vanilla' + +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) + }) + + function Component() { + const count = useAtomValue(asyncAtom) + return
count: {count}
+ } + + function 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) + }) + + function Component() { + const count = useAtomValue(asyncAtom, { delay: 0 }) + return
count: {count}
+ } + + function 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) + }, + ) + + function Component() { + const text = useAtomValue(derivedAtom) + return
text: {text}
+ } + + function 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 + }) + + function Component() { + const count = useAtomValue(asyncAtom) + return
count: {count}
+ } + + function 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) + }) + + function 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) + }) + + function 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) + }) + + function 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..1625afb --- /dev/null +++ b/__tests__/derive/baseTests/react/basic.test.tsx @@ -0,0 +1,945 @@ +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) + + function 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) + + function 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), + ) + + function 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)) + + function Counter() { + const [count] = useAtom(countAtom) + return ( +
+ commits: {useCommitCount()}, count: {count} +
+ ) + } + + function 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 } + function Counter({ countAtom, name }: Props) { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
+ commits: {useCommitCount()}, {name}: {count} +
+ + + ) + } + + function 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 + }) + + function 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) + }) + + function 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) + }) + + function 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))) + }) + }, + ) + + function 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) + }, + ) + + function 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))) + }) + + function 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) + }) + + function 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) + + function 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) + + function Child({ setCount }: { setCount: (f: (c: number) => number) => void }) { + useEffect(() => { + setCount((c) => c + 1) + }, [setCount]) + return null + } + + function 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() + + function 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) + }) + + function 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)) + + function 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) + + function Child() { + const [countB, setCountB] = useAtom(countBAtom) + useEffect(() => { + setCountB((c) => c + 1) + }, [setCountB]) + return
countB: {countB}
+ } + + function 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() + + function 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}
+ } + + function 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' }) + + function Item({ id }: { id: string }) { + const a = useMemo(() => (id === 'a' ? atomA : atomB), [id]) + const [atomValue] = useAtom(a) + return ( +
+ commits: {useCommitCount()}, id: {atomValue.id} +
+ ) + } + + function 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) + + function 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) + }) + + function 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)) + + function 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] + + function 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), + })) + + function 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) + }) + + function 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)] + + function 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..96771e2 --- /dev/null +++ b/__tests__/derive/baseTests/react/dependency.test.tsx @@ -0,0 +1,1012 @@ +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) + + function 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 + }) + + function 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) + + function Counter() { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + function DerivedCounter() { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + function 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) + + function Counter() { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + function DerivedCounter() { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + function 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) + + function Counter() { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + function 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) + + function Counter() { + const [count] = useAtom(countAtom) + const [, setData] = useAtom(dataAtom) + return ( + <> +
count: {count}
+ + + ) + } + + function 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() + }, + ) + + function 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) + }) + + function 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)) + + function Counter() { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + function DerivedCounter() { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + function 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) + }) + + function Counter() { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + function DerivedCounter() { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + function 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 + }) + + function Counter() { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + function DerivedCounter() { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + function 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) + }) + + function DerivedCounter() { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + function 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) + }) + + function Input() { + const [result] = useAtom(derivedAtom) + return
{result}
+ } + + function 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) + }) + + function Input() { + const [result] = useAtom(derivedAtom) + return
{result}
+ } + + function 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 + }) + + function 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) + + function App() { + const value = useAtomValue(derived3Atom) + return
value: {value}
+ } + + function 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) + + function App() { + const value = useAtomValue(derived3Atom) + return
value: {value}
+ } + + function 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) + + function App() { + const value = useAtomValue(derived4Atom) + return
value: {value}
+ } + + function 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 + + function Component() { + const memoizedAtomRef = useRef | null>(null) + if (!memoizedAtomRef.current) { + const derivedFn = jest.fn((get: Getter) => get(countAtom)) + if (!firstDerivedFn) { + firstDerivedFn = derivedFn + } + memoizedAtomRef.current = atom(derivedFn) + } + useAtomValue(memoizedAtomRef.current) + return null + } + + function 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) + }) + + function 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 + }) + + function Count() { + const count = useAtomValue(totalCountAtom) + return

count: {count}

+ } + function 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..4e80868 --- /dev/null +++ b/__tests__/derive/baseTests/react/error.test.tsx @@ -0,0 +1,544 @@ +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' + +// eslint-disable-next-line no-console +const consoleError = console.error +const errorMessages: string[] = [] +beforeEach(() => { + errorMessages.splice(0) + // eslint-disable-next-line no-console + console.error = jest.fn((err: string) => { + const match = /^(.*?)(\n|$)/.exec(err) + if (match?.[1]) { + errorMessages.push(match[1]) + } + }) +}) +afterEach(() => { + // eslint-disable-next-line no-console + 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() + }) + + function 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() + }) + + function 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)) + + function 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)) + + function 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() + }) + + function 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() + }) + + function 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') + }, + ) + + function Counter() { + const [count, dispatch] = useAtom(errorAtom) + const onClick = () => { + try { + dispatch() + } catch (e) { + // eslint-disable-next-line no-console + 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') + }, + ) + + function Counter() { + const [count, dispatch] = useAtom(errorAtom) + const onClick = async () => { + try { + await dispatch() + } catch (e) { + // eslint-disable-next-line no-console + 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) + }, + ) + + function Counter() { + const [count, dispatch] = useAtom(chainedAtom) + const onClick = () => { + try { + dispatch() + } catch (e) { + // eslint-disable-next-line no-console + 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) + + function Counter() { + const [, setCount] = useAtom(countAtom) + useEffect(() => { + try { + setCount(() => { + throw new Error('err_updating_in_effect') + }) + } catch (e) { + // eslint-disable-next-line no-console + 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 + + function 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
+ } + + function 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) + + function 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 + }) + + function 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 + }) + + function 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..019e8a7 --- /dev/null +++ b/__tests__/derive/baseTests/react/items.test.tsx @@ -0,0 +1,169 @@ +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[]>([]) + + function 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'} +
+ + + + ) + } + + function 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) + }) + + function 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'} +
+ + + + ) + } + + function Filter() { + const [filter, setFilter] = useAtom(filterAtom) + return ( + <> +
{filter}
+ + + + + ) + } + + function FilteredList({ removeItem }: { removeItem: (itemAtom: PrimitiveAtom) => void }) { + const [items] = useAtom(filteredAtom) + return ( +
    + {items.map((itemAtom) => ( + removeItem(itemAtom)} /> + ))} +
+ ) + } + + function 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..667b2c7 --- /dev/null +++ b/__tests__/derive/baseTests/react/onmount.test.tsx @@ -0,0 +1,474 @@ +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 + + function 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 + + function 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 + + function 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 + + function Counter() { + const [count] = useAtom(countAtom) + return
count: {count}
+ } + + function 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 + + function Counter() { + const [count] = useAtom(derivedAtom) + return
count: {count}
+ } + + function 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 + + function Counter2() { + const [count] = useAtom(derivedAtom) + return
count: {count}
+ } + function Counter() { + const [count] = useAtom(countAtom) + const [display, setDisplay] = useState(false) + return ( + <> +
count: {count}
+ + {display ? : null} + + ) + } + + function 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 + + function Counter() { + const [count] = useAtom(countAtom) + return
count: {count}
+ } + + function 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) + } + + function Counter() { + const [count] = useAtom(countAtom) + return
count: {count}
+ } + + function 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) + }, + ) + + function 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) + }, + ) + + function 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..7cb9d50 --- /dev/null +++ b/__tests__/derive/baseTests/react/optimization.test.tsx @@ -0,0 +1,265 @@ +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 + + function Counter1() { + const [count, setCount] = useAtom(count1Atom) + ++renderCount1 + return ( + <> +
count1: {count}
+ + + ) + } + + function 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 + + function Counter() { + const [count1] = useAtom(count1Atom) + const [count2] = useAtom(count2Atom) + ++renderCount + return ( +
+ count1: {count1}, count2: {count2} +
+ ) + } + + function 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 + + function 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 + + function 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 + + function Counter1() { + const [count1] = useAtom(count1Atom) + ++renderCount1 + useEffect(() => { + renderCount1AfterCommit = renderCount1 + }) + return
count1: {count1}
+ } + + let renderCount2 = 0 + let renderCount2AfterCommit = 0 + + function Counter2() { + const [count2] = useAtom(count2Atom) + ++renderCount2 + useEffect(() => { + renderCount2AfterCommit = renderCount2 + }) + return
count2: {count2}
+ } + + function 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..fbeeb66 --- /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') + + function 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') + + function 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..94b14a8 --- /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) + + function 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..5cf3d44 --- /dev/null +++ b/__tests__/derive/baseTests/react/useSetAtom.test.tsx @@ -0,0 +1,111 @@ +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) + + function Displayer() { + const count = useAtomValue(countAtom) + const commits = useCommitCount() + return ( +
+ count: {count}, commits: {commits} +
+ ) + } + + function Updater() { + const setCount = useSetAtom(countAtom) + const commits = useCommitCount() + return ( + <> + +
updater commits: {commits}
+ + ) + } + + function 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)) + + function Button({ cb, children }: PropsWithChildren<{ cb: () => void }>) { + return + } + + function Displayer() { + const count = useAtomValue(countAtom) + return
count: {count}
+ } + + function Updater() { + const setCount = useSetAtom(incrementCountAtom) + return + } + + function 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..ebc9d95 --- /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..d94343d --- /dev/null +++ b/__tests__/derive/baseTests/react/utils/useAtomCallback.test.tsx @@ -0,0 +1,167 @@ +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) + + function Counter() { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
atom count: {count}
+ + + ) + } + + function 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) + function Counter() { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + function 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) + + function 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) + + function 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..d8b6570 --- /dev/null +++ b/__tests__/derive/baseTests/react/utils/useHydrateAtoms.test.tsx @@ -0,0 +1,296 @@ +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') + + function 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) + + function Counter({ + initialActive = false, + initialCount, + }: { + initialActive?: boolean + initialCount: number + }) { + useHydrateAtoms( + new Map([ + [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) + + function 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) + + function 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) + + function Counter({ initialCount }: { initialCount: number }) { + useHydrateAtoms([[countAtom, initialCount]]) + const [countValue, setCount] = useAtom(countAtom) + + return ( + <> +
count: {countValue}
+ + + ) + } + function 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 + + function 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') + + function 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..29c9d91 --- /dev/null +++ b/__tests__/derive/baseTests/react/utils/useReducerAtom.test.tsx @@ -0,0 +1,128 @@ +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(() => { + // eslint-disable-next-line no-console + savedConsoleWarn = console.warn + // eslint-disable-next-line no-console + console.warn = jest.fn() +}) +afterEach(() => { + // eslint-disable-next-line no-console + console.warn = savedConsoleWarn +}) + +it('useReducerAtom with no action argument', async () => { + const countAtom = atom(0) + const reducer = (state: number) => state + 2 + + function 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: + default: + return state + } + } + + function 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': + default: + return state - 1 + } + } + + function 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..c1525ef --- /dev/null +++ b/__tests__/derive/baseTests/react/utils/useResetAtom.test.tsx @@ -0,0 +1,167 @@ +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) + + function 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) + + function 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), + ) + + function 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) + + function 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..290c17c --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/atomFamily.test.tsx @@ -0,0 +1,269 @@ +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)) + + function 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)) + + function Displayer({ index }: { index: number }) { + const [count, setCount] = useAtom(myFamily(index)) + return ( +
+ count: {count} + +
+ ) + } + + function 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 + }) + }, + ), + ) + + function 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)]) + + function 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)))) + + function 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..9048643 --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/atomWithDefault.test.tsx @@ -0,0 +1,202 @@ +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) + + function 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 + }) + + function 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) + + function 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 + }) + + function 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..4fa3daf --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/atomWithReducer.test.tsx @@ -0,0 +1,87 @@ +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: + default: + return state + } + } + const countAtom = atomWithReducer(0, reducer) + + function 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': + default: + return state - 1 + } + } + const countAtom = atomWithReducer(0, reducer) + + function 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..f753411 --- /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) + + function 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 + }) + + function 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 + }, + ) + + function 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..72486f5 --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/freezeAtom.test.tsx @@ -0,0 +1,75 @@ +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: {} }) + }) + + function 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(() => { + // eslint-disable-next-line no-console + savedConsoleWarn = console.warn + // eslint-disable-next-line no-console + console.warn = jest.fn() + }) + afterEach(() => { + // eslint-disable-next-line no-console + console.warn = savedConsoleWarn + }) + + it('freezeAtomCreator basic test', async () => { + const createFrozenAtom = freezeAtomCreator(atom) + const objAtom = createFrozenAtom({ deep: {} }, (_get, set, _ignored?) => { + set(objAtom, { deep: {} }) + }) + + function 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..dfadb48 --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/loadable.test.tsx @@ -0,0 +1,281 @@ +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)) + }) + + function 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 + }) + }) + + function 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() + + function 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)) + }) + + function 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 +} + +function 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 } = value + + 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..1474f2b --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/selectAtom.test.tsx @@ -0,0 +1,115 @@ +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) + + function Parent() { + const setValue = useSetAtom(bigAtom) + return ( + + ) + } + + function 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), + ) + + function Parent() { + const setValue = useSetAtom(bigAtom) + return ( + <> + + + + ) + } + + function 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..d357434 --- /dev/null +++ b/__tests__/derive/baseTests/react/vanilla-utils/splitAtom.test.tsx @@ -0,0 +1,527 @@ +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 }, + ]) + + function TaskList({ listAtom }: { listAtom: typeof todosAtom }) { + const [atoms] = useAtom(splitAtom(listAtom)) + return ( + <> + TaskListUpdates: {useCommitCount()} + {atoms.map((anAtom) => ( + + ))} + + ) + } + + function TaskItem({ itemAtom }: { itemAtom: PrimitiveAtom }) { + 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 }, + ]) + + function TaskList({ listAtom }: { listAtom: typeof todosAtom }) { + const [atoms, dispatch] = useAtom(splitAtom(listAtom)) + return ( + <> + {atoms.map((anAtom) => ( + dispatch({ type: 'remove', atom: anAtom })} + itemAtom={anAtom} + /> + ))} + + ) + } + + function 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' }, + ]) + + function 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 + function 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' }, + ]) + + function 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} + /> + ))} +
    + ) + } + + function 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 }, + ]) + + function TaskList({ listAtom }: { listAtom: typeof todosAtom }) { + const [atoms] = useAtom(splitAtom(listAtom)) + return ( + <> + {atoms.map((anAtom) => ( + + ))} + + ) + } + + function 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 } + + function 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 + } + + function 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..256a6ec --- /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..97139b5 --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/dependency.test.tsx @@ -0,0 +1,274 @@ +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 [] + } + return data + }) + const stageAtom = atom((get) => { + const hasFilter = get(hasFilterAtom) + if (hasFilter) { + const filtered = get(filteredAtom) + return filtered.length === 0 ? 'is-empty' : 'has-data' + } + 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..825cacf --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/store.test.tsx @@ -0,0 +1,578 @@ +import { waitFor } from '@testing-library/dom' +import assert from 'minimalistic-assert' +import { atom } from '../../../../jotai/vanilla' +import type { Getter } from '../../../../jotai/vanilla' +import { createStore } from '../../derivedStore' + +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..8e4bfe6 --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/storedev.test.tsx @@ -0,0 +1,94 @@ +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..fd0367c --- /dev/null +++ b/__tests__/derive/baseTests/vanilla/unstable_derive.test.tsx @@ -0,0 +1,292 @@ +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..25b393d --- /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..123b9a9 --- /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..9159e19 --- /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..6ca04b0 --- /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..1d05b17 --- /dev/null +++ b/__tests__/derive/derivedStore.ts @@ -0,0 +1,26 @@ +import { createStore as baseCreateStore } from '../../jotai' +import { AnyAtom } from './types' + +export function createStore(scopedAtoms: Set = new Set()) { + const store = baseCreateStore() + const derivedStore = store.unstable_derive((getAtomState) => { + return [ + function customGetAtomState(atom, originAtomState) { + return getAtomState(atom, originAtomState) + }, + function atomReadTrap(atom, getter, options) { + return atom.read(getter, options) + }, + function atomWriteTrap(atom, getter, setter, ...args) { + return atom.write(getter, setter, ...args) + }, + ] + }) + 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..5431188 --- /dev/null +++ b/__tests__/derive/types.ts @@ -0,0 +1,7 @@ +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 diff --git a/__tests__/derive/understanding/atomState.test.ts b/__tests__/derive/understanding/atomState.test.ts new file mode 100644 index 0000000..a391f3e --- /dev/null +++ b/__tests__/derive/understanding/atomState.test.ts @@ -0,0 +1,327 @@ +import { AtomState } from 'src/ScopeProvider2/types' +import { atom, createStore } from '../../../jotai' +import { assertIsDevStore } from '../../utils' + +const store = createStore() +assertIsDevStore(store) +const stateMap = store.dev4_get_internal_weak_map() + +const atomA = atom(0) +atomA.debugLabel = 'atomA' +const atomB = atom((get) => String(get(atomA))) +atomB.debugLabel = 'atomB' +let atomAState: AtomState +let atomBState: AtomState +let atomCState: AtomState +let unsub: () => void + +it('sets d when an atom has a consumer', () => { + // atomB depends on atomA + // atomB is a consumer of atomA + // atomA is a producer for atomB + + store.get(atomB) + + atomAState = stateMap.get(atomA)! + atomBState = stateMap.get(atomB)! + /* + AtomA state: { d: Map(0) {}, p: Set(0) {}, n: 1, v: 0 } + + AtomB state: { + d: Map(1) { atomA => 1 }, + p: Set(0) {}, + n: 1, + v: '0', + } + */ + expect(atomBState.d.has(atomA)).toBe(true) +}) + +it('mounts the atoms when an atom has a subscriber', () => { + function onAUnmount() {} + function onAMount() { + return onAUnmount + } + atomA.onMount = onAMount + + function subscribeB() {} + unsub = store.sub(atomB, subscribeB) + + /* + AtomA state: { + d: Map(0) {}, + p: Set(0) {}, + n: 1, + v: 0, + m: { + l: Set(0) {}, + d: Set(0) {}, + t: Set(1) { [atomB] } + u: [Function onAUnmount] + } + } + + AtomB state: { + d: Map(1) { atomA => 1 }, + p: Set(0) {}, + n: 1, + v: '0', + m: { + l: Set(1) { [Function: subscribeB] }, + d: Set(1) { [atomA] }, + t: Set(0) {} + } + } + */ + + expect(atomBState.d.has(atomA)).toBe(true) + expect(atomBState.m!.d.has(atomA)).toBe(true) + expect(atomAState.m!.t.has(atomB)).toBe(true) + expect(atomBState.m!.l.has(subscribeB)).toBe(true) + expect(atomAState.m!.u!).toBe(onAUnmount) + delete atomA.onMount +}) + +it('increments the epoch number when an atom is updated', () => { + store.set(atomA, 1) + + /* + AtomA state: { + d: Map(0) {}, + p: Set(0) {}, + n: 2, + v: 1, + m: { + l: Set(0) {}, + d: Set(0) {}, + t: Set(1) { [atomB] } + u: [Function onAUnmount] + } + } + + AtomB state: { + d: Map(1) { atomA => 2 }, + p: Set(0) {}, + n: 2, + v: '1', + m: { + l: Set(1) { [Function: subscribeB] }, + d: Set(1) { [atomA] }, + t: Set(0) {} + } + } + */ + + expect(atomAState.n).toBe(2) + expect(atomBState.n).toBe(2) + unsub() +}) + +it('unmounts the atoms when there are no subscribers', () => { + /* + AtomA state: { d: Map(0) {}, p: Set(0) {}, n: 2, v: 1 } + + AtomB state: { + d: Map(1) { atomA => 2 }, + p: Set(0) {}, + n: 2, + v: '1', + } + */ + expect(atomBState.m).toBeUndefined() + expect(atomAState.m).toBeUndefined() +}) + +it('does not automatically increment the epoch number when the dependent is not mounted', () => { + store.set(atomA, 2) + + /* + AtomA state: { d: Map(0) {}, p: Set(0) {}, n: 3, v: 2 } + + AtomB state: { + d: Map(1) { atomA => 2 }, + p: Set(0) {}, + n: 2, + v: '1', + } + */ + + expect(atomAState.n).toBe(3) + expect(atomBState.n).toBe(2) +}) + +it('increments the epoch number when the dependent is read', () => { + store.get(atomB) + + /* + AtomA state: { d: Map(0) {}, p: Set(0) {}, n: 3, v: 2 } + + AtomB state: { + d: Map(1) { atomA => 3 }, + p: Set(0) {}, + n: 3, + v: '2', + } + */ + expect(atomBState.n).toBe(3) +}) + +it('increments the epoch number when the dependent is mounted', () => { + store.set(atomA, 3) + expect(atomAState.n).toBe(4) + expect(atomBState.n).toBe(3) + unsub = store.sub(atomB, function subscribeB() {}) + + /* + AtomA state: { + d: Map(0) {}, + p: Set(0) {}, + n: 4, + v: 3, + m: { + l: Set(0) {}, + d: Set(0) {}, + t: Set(1) { [atomB] } + } + } + + AtomB state: { + d: Map(1) { atomA => 4 }, + p: Set(0) {}, + n: 4, + v: '3', + m: { + l: Set(1) { [Function: subscribeB] }, + d: Set(1) { [atomA] }, + t: Set(0) {} + } + } + */ + expect(atomBState.n).toBe(4) + unsub() +}) + +let resolve: (value: number) => void +const atomC = atom((get) => { + get(atomB) + return new Promise((r) => { + resolve = r + }) +}) +atomC.debugLabel = 'atomC' + +it('sets p when an atom has a pending consumer', async () => { + store.get(atomC) + atomCState = stateMap.get(atomC)! + + const unsubA = store.sub(atomA, function subscribeA() {}) + const unsubB = store.sub(atomB, function subscribeB() {}) + const unsubC = store.sub(atomC, function subscribeC() {}) + + /* + AtomA state: { + d: Map(0) {}, + p: Set(0) {}, + n: 4, + v: 3, + m: { + l: Set(1) { [Function: subscribeA] }, + d: Set(0) {}, + t: Set(1) { [atomB] } + } + } + + AtomB state: { + d: Map(1) { atomA => 4 }, + p: Set(1) { [atomC] }, + n: 4, + v: '3', + m: { + l: Set(1) { [Function: subscribeB] }, + d: Set(1) { [atomA] }, + t: Set(1) { [atomC] } + } + } + + AtomC state: { + d: Map(1) { atomB => 4 }, + p: Set(0) {}, + n: 1, + v: Promise { , onCancel: [Function (anonymous)] }, + m: { + l: Set(1) { [Function: subscribeC] }, + d: Set(1) { [atomB] }, + t: Set(0) {} + } + } + */ + expect(atomBState.p.has(atomC)).toBe(true) + + resolve(0) + await 'microtask' + + /* + AtomA state: { + d: Map(0) {}, + p: Set(0) {}, + n: 4, + v: 3, + m: { + l: Set(1) { [Function: subscribeA] }, + d: Set(0) {}, + t: Set(1) { [atomB] } + } + } + + AtomB state: { + d: Map(1) { atomA => 4}, + p: Set(0) {}, + n: 4, + v: '3', + m: { + l: Set(1) { [Function: subscribeB] }, + d: Set(1) { [atomA] }, + t: Set(1) { [atomC] } + } + } + + AtomC state: { + d: Map(1) { atomB => 4 }, + p: Set(0) {}, + n: 1, + v: Promise { 0, onCancel: [Function (anonymous)] }, + m: { + l: Set(1) { [Function: subscribeC] }, + d: Set(1) { [atomB] }, + t: Set(0) {} + } + } + */ + unsubA() + unsubB() + unsubC() +}) + +it('sets e when an atom throws an error', () => { + const atomD = atom(() => { + throw new Error('error') + }) + atomD.debugLabel = 'atomD' + try { + store.get(atomD) + } catch { + // ignore + } + const atomDState = stateMap.get(atomD)! + + /* + AtomD state: { + d: Map(0) {}, + p: Set(0) {}, + n: 1, + e: Error: error + at ... + } + */ + expect(atomDState.e).toBeInstanceOf(Error) +}) diff --git a/__tests__/derive/understanding/unstable_derive.test.ts b/__tests__/derive/understanding/unstable_derive.test.ts new file mode 100644 index 0000000..7de6c41 --- /dev/null +++ b/__tests__/derive/understanding/unstable_derive.test.ts @@ -0,0 +1,511 @@ +/* eslint-disable no-sparse-arrays */ +import type { AtomState } from 'src/ScopeProvider2/types' +import { atom, createStore, type Getter, type Setter, type SetStateAction } from '../../../jotai' +import { assertIsDevStore } from '../../utils' +import { AnyAtom, AnyWritableAtom } from '../types' + +type AtomStateWithLabel = AtomState & { label?: string } +type Store = ReturnType +type DeriveCallack = Parameters[0] +type GetAtomState = ReturnType[0] +type AtomReadTrap = ReturnType[1] +type AtomWriteTrap = ReturnType[2] + +let getAtomState: GetAtomState +let atomReadTrap: AtomReadTrap & jest.Mock +let atomWriteTrap: AtomWriteTrap & jest.Mock + +const deriveCallback: DeriveCallack = jest.fn((baseGetAtomState) => { + getAtomState = jest.fn((atom, baseAtomState) => { + return baseGetAtomState(atom, baseAtomState) + }) + atomReadTrap = jest.fn((atom, getter, options) => { + const atomReadGetter: any = jest.fn((a) => getter(a)) + atomReadGetterMap.set(getter, atomReadGetter) + return atom.read(atomReadGetter as Getter, options) + }) as any + atomWriteTrap = jest.fn((atom, getter, setter, ...params) => { + const atomWriteGetter: any = jest.fn((a) => getter(a)) + const atomWriteSetter: any = jest.fn((a, ...v) => setter(a, ...v)) + atomWriteGetterMap.set(getter, atomWriteGetter) + atomWriteSetterMap.set(setter, atomWriteSetter) + return atom.write(atomWriteGetter as Getter, atomWriteSetter as Setter, ...params) + }) as any + return [getAtomState, atomReadTrap, atomWriteTrap] +}) +const store = createStore().unstable_derive(deriveCallback) +assertIsDevStore(store) +const stateMap = store.dev4_get_internal_weak_map() + +let atomReadGetterMap = new Map>() +let atomWriteGetterMap = new Map>() +let atomWriteSetterMap = new Map< + Setter, + Setter & jest.Mock +>() +function nthReadParams(nthCall: number, guessParams: any[] = []) { + return Object.assign([], atomReadTrap.mock.calls[nthCall - 1]!.slice(), guessParams) +} +function nthWriteParams(nthCall: number, guessParams: any[] = []) { + return Object.assign([], atomWriteTrap.mock.calls[nthCall - 1]!.slice(), guessParams) +} +function getAccessor( + map: Map, + trap: { mock: { calls: Array<[any, any, any, ...rest: any[]]> } }, + paramIndex: number, +) { + return (nthCall: number) => map.get(trap.mock.calls[nthCall - 1]![paramIndex])! +} +const nthAtomReadGetter = getAccessor(atomReadGetterMap, atomReadTrap!, 1) +const nthAtomWriteGetter = getAccessor(atomWriteGetterMap, atomWriteTrap!, 1) +const nthAtomWriteSetter = getAccessor(atomWriteSetterMap, atomWriteTrap!, 2) + +const atomA = atom(0) +atomA.debugLabel = 'atomA' +const atomB = atom((get) => String(get(atomA))) +atomB.debugLabel = 'atomB' +const atomC = atom(null, (get, set, value: SetStateAction) => { + set(atomA, value) +}) +atomC.debugLabel = 'atomC' +let resolve: (value: unknown) => void +const atomD = atom((get) => { + get(atomA) + return new Promise((r) => { + resolve = r + }) +}) +atomD.debugLabel = 'atomD' +const atomE = atom( + (get, { setSelf }) => { + Promise.resolve().then(() => { + setSelf() + }) + }, + (get, set) => {}, +) +atomE.debugLabel = 'atomE' + +let atomAState: AtomStateWithLabel +let atomBState: AtomStateWithLabel +let atomCState: AtomStateWithLabel +let atomDState: AtomStateWithLabel +let atomState: AtomStateWithLabel + +function increment(v: number) { + return v + 1 +} + +describe('calls GAS and accessor traps on', () => { + beforeEach(() => { + jest.clearAllMocks() + atomReadGetterMap.clear() + atomWriteGetterMap.clear() + atomWriteSetterMap.clear() + }) + + it('first store.get(primitiveAtom)', () => { + store.get(atomA) + expect(getAtomState).toHaveBeenCalledTimes(2) + atomAState = stateMap.get(atomA)! + atomAState.label = atomA.debugLabel! + expect(getAtomState).nthCalledWith(1, atomA) + expect(getAtomState).nthCalledWith(2, atomA, atomAState) + + expect(atomReadTrap).toHaveBeenCalledTimes(1) + expect(atomReadTrap).toHaveBeenNthCalledWith(1, ...nthReadParams(1, [atomA])) + expect(nthAtomReadGetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomReadGetter(1)).toHaveBeenCalledWith(atomA) + }) + + it('store.get(primitiveAtom)', () => { + store.get(atomA) + expect(getAtomState).toHaveBeenCalledTimes(1) + expect(getAtomState).nthCalledWith(1, atomA) + + expect(atomReadTrap).toHaveBeenCalledTimes(0) // atomRead is cached + }) + + it('initial store.get(derivedAtom)', () => { + store.get(atomB) + expect(getAtomState).toHaveBeenCalledTimes(2) + atomBState = stateMap.get(atomB)! + atomBState.label = atomB.debugLabel! + expect(getAtomState).nthCalledWith(1, atomB) + expect(getAtomState).nthCalledWith(2, atomA, atomBState) + + expect(atomReadTrap).toHaveBeenCalledTimes(1) + expect(atomReadTrap).toHaveBeenNthCalledWith(1, ...nthReadParams(1, [atomB])) + expect(nthAtomReadGetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomReadGetter(1)).toHaveBeenCalledWith(atomA) + }) + + it('store.get(derivedAtom)', () => { + store.get(atomB) + expect(getAtomState).toHaveBeenCalledTimes(2) + expect(getAtomState).nthCalledWith(1, atomB) + expect(getAtomState).nthCalledWith(2, atomA, atomBState) + + expect(atomReadTrap).toHaveBeenCalledTimes(0) // atomRead is cached + }) + + it('store.get(asyncDerivedAtom)', async () => { + store.get(atomD) + expect(getAtomState).toHaveBeenCalledTimes(3) + + atomDState = stateMap.get(atomD)! + atomDState.label = atomD.debugLabel! + expect(getAtomState).nthCalledWith(1, atomD) + expect(getAtomState).nthCalledWith(2, atomA, atomDState) + expect(getAtomState).nthCalledWith(3, atomA, atomDState) + + expect(atomReadTrap).toHaveBeenCalledTimes(1) + expect(atomReadTrap).toHaveBeenNthCalledWith(1, ...nthReadParams(1, [atomD])) + jest.clearAllMocks() + resolve(1) + await 'microtask' + // does not call GAS or atomRead when promise resolves + expect(getAtomState).toHaveBeenCalledTimes(0) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + }) + + it('store.set(primitiveAtom, value)', () => { + store.set(atomA, 1) + expect(getAtomState).toHaveBeenCalledTimes(2) + expect(getAtomState).nthCalledWith(1, atomA) + expect(getAtomState).nthCalledWith(2, atomA, atomAState) + + expect(atomReadTrap).toHaveBeenCalledTimes(0) + expect(atomWriteTrap).toHaveBeenCalledTimes(1) + expect(atomWriteTrap).toHaveBeenNthCalledWith(1, ...nthWriteParams(1, [atomA, , , 1])) + expect(nthAtomWriteGetter(1)).toHaveBeenCalledTimes(0) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledWith(atomA, 1) + }) + + it('store.set(primitiveAtom, (currValue) => nextValue)', () => { + store.set(atomA, increment) + expect(getAtomState).toHaveBeenCalledTimes(3) + expect(getAtomState).nthCalledWith(1, atomA) + expect(getAtomState).nthCalledWith(2, atomA, atomAState) + expect(getAtomState).nthCalledWith(3, atomA, atomAState) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + expect(atomWriteTrap).toHaveBeenCalledTimes(1) + expect(atomWriteTrap).toHaveBeenNthCalledWith(1, ...nthWriteParams(1, [atomA, , , increment])) + expect(nthAtomWriteGetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteGetter(1)).toHaveBeenCalledWith(atomA) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledWith(atomA, 2) + }) + + it('store.set(writableAtom, value)', () => { + store.set(atomC, 3) + expect(getAtomState).toHaveBeenCalledTimes(3) + store.get(atomC) + atomCState = stateMap.get(atomC)! + atomCState.label = atomC.debugLabel! + expect(getAtomState).nthCalledWith(1, atomC) + expect(getAtomState).nthCalledWith(2, atomA, atomCState) + expect(getAtomState).nthCalledWith(3, atomA, atomAState) + + expect(atomReadTrap).toHaveBeenCalledTimes(1) + expect(atomReadTrap).toHaveBeenNthCalledWith(1, ...nthReadParams(1, [atomC])) + expect(nthAtomReadGetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomReadGetter(1)).toHaveBeenCalledWith(atomC) + expect(atomWriteTrap).toHaveBeenCalledTimes(2) + expect(atomWriteTrap).toHaveBeenNthCalledWith(1, ...nthWriteParams(1, [atomC, , , 3])) + expect(nthAtomWriteGetter(1)).toHaveBeenCalledTimes(0) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledWith(atomA, 3) + expect(atomWriteTrap).toHaveBeenNthCalledWith(2, ...nthWriteParams(2, [atomA, , , 3])) + expect(nthAtomWriteGetter(2)).toHaveBeenCalledTimes(0) + expect(nthAtomWriteSetter(2)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteSetter(2)).toHaveBeenCalledWith(atomA, 3) + }) + + it('store.set(writableAtom, currValue => nextValue)', () => { + store.set(atomC, increment) + expect(getAtomState).toHaveBeenCalledTimes(4) + expect(getAtomState).nthCalledWith(1, atomC) + expect(getAtomState).nthCalledWith(2, atomA, atomCState) + expect(getAtomState).nthCalledWith(3, atomA, atomAState) + expect(getAtomState).nthCalledWith(4, atomA, atomAState) + + expect(atomReadTrap).toHaveBeenCalledTimes(0) + expect(atomWriteTrap).toHaveBeenCalledTimes(2) + expect(atomWriteTrap).toHaveBeenNthCalledWith(1, ...nthWriteParams(1, [atomC, , , increment])) + expect(nthAtomWriteGetter(1)).toHaveBeenCalledTimes(0) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledWith(atomA, increment) + expect(atomWriteTrap).toHaveBeenNthCalledWith(2, ...nthWriteParams(2, [atomA, , , increment])) + expect(nthAtomWriteGetter(2)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteGetter(2)).toHaveBeenCalledWith(atomA) + expect(nthAtomWriteSetter(2)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteSetter(2)).toHaveBeenCalledWith(atomA, 4) + }) + + it('store.sub(primativeAtom, () => {})', () => { + const unsubA = store.sub(atomA, () => {}) + expect(getAtomState).toHaveBeenCalledTimes(1) + expect(getAtomState).nthCalledWith(1, atomA) + + expect(atomReadTrap).toHaveBeenCalledTimes(0) + unsubA() + expect(getAtomState).toHaveBeenCalledTimes(1) + expect(getAtomState).nthCalledWith(1, atomA) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + }) + + it('store.sub(derivedAtom, () => {})', () => { + const unsubB = store.sub(atomB, () => {}) + expect(getAtomState).toHaveBeenCalledTimes(4) + expect(getAtomState).nthCalledWith(1, atomB) + expect(getAtomState).nthCalledWith(2, atomA, atomBState) + expect(getAtomState).nthCalledWith(3, atomA, atomBState) + expect(getAtomState).nthCalledWith(4, atomA, atomBState) + + expect(atomReadTrap).toHaveBeenCalledTimes(1) + expect(atomReadTrap).toHaveBeenNthCalledWith(1, ...nthReadParams(1, [atomB])) + expect(nthAtomReadGetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomReadGetter(1)).toHaveBeenCalledWith(atomA) + unsubB() + expect(getAtomState).toHaveBeenCalledTimes(6) + expect(getAtomState).nthCalledWith(1, atomB) + expect(getAtomState).nthCalledWith(2, atomA, atomBState) + expect(getAtomState).nthCalledWith(3, atomA, atomBState) + expect(getAtomState).nthCalledWith(4, atomA, atomBState) + expect(getAtomState).nthCalledWith(5, atomA, atomBState) + expect(getAtomState).nthCalledWith(6, atomB, atomAState) + expect(atomReadTrap).toHaveBeenCalledTimes(1) + expect(atomReadTrap).toHaveBeenNthCalledWith(1, ...nthReadParams(1, [atomB])) + expect(nthAtomReadGetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomReadGetter(1)).toHaveBeenCalledWith(atomA) + }) + + it('store.sub(writableAtom, () => {})', () => { + const unsubC = store.sub(atomC, () => {}) + expect(getAtomState).toHaveBeenCalledTimes(1) + expect(getAtomState).nthCalledWith(1, atomC) + + expect(atomReadTrap).toHaveBeenCalledTimes(0) + unsubC() + expect(getAtomState).toHaveBeenCalledTimes(1) + expect(getAtomState).nthCalledWith(1, atomC) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + }) + + it('atomA unmount', () => { + const unsubA = store.sub(atomA, () => {}) + unsubA() + expect(getAtomState).toHaveBeenCalledTimes(1) + expect(getAtomState).nthCalledWith(1, atomA) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + }) + + it('atomA unmount when atomB is still mounted', () => { + const unsubA = store.sub(atomA, () => {}) + const unsubB = store.sub(atomB, () => {}) + unsubA() + expect(getAtomState).toHaveBeenCalledTimes(5) + expect(getAtomState).nthCalledWith(1, atomA) + expect(getAtomState).nthCalledWith(2, atomB) + expect(getAtomState).nthCalledWith(3, atomA, atomBState) + expect(getAtomState).nthCalledWith(4, atomA, atomBState) + expect(getAtomState).nthCalledWith(5, atomB, atomAState) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + jest.clearAllMocks() + unsubB() + expect(getAtomState).toHaveBeenCalledTimes(2) + expect(getAtomState).nthCalledWith(1, atomA, atomBState) + expect(getAtomState).nthCalledWith(2, atomB, atomAState) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + }) + + it('atomB unmount', () => { + const unsubB = store.sub(atomB, () => {}) + unsubB() + expect(getAtomState).toHaveBeenCalledTimes(5) + expect(getAtomState).nthCalledWith(1, atomB) + expect(getAtomState).nthCalledWith(2, atomA, atomBState) + expect(getAtomState).nthCalledWith(3, atomA, atomBState) + expect(getAtomState).nthCalledWith(4, atomA, atomBState) + expect(getAtomState).nthCalledWith(5, atomB, atomAState) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + }) + + it('atomB unmount when atomA is still mounted', () => { + const unsubA = store.sub(atomA, () => {}) + const unsubB = store.sub(atomB, () => {}) + unsubB() + expect(getAtomState).toHaveBeenCalledTimes(5) + expect(getAtomState).nthCalledWith(1, atomA) + expect(getAtomState).nthCalledWith(2, atomB) + expect(getAtomState).nthCalledWith(3, atomA, atomBState) + expect(getAtomState).nthCalledWith(4, atomA, atomBState) + expect(getAtomState).nthCalledWith(5, atomA, atomBState) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + jest.clearAllMocks() + unsubA() + expect(getAtomState).toHaveBeenCalledTimes(0) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + }) + + it('atomC unmount', () => { + const unsubC = store.sub(atomC, () => {}) + unsubC() + expect(getAtomState).toHaveBeenCalledTimes(1) + expect(getAtomState).nthCalledWith(1, atomC) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + }) + + it('atomA mount setSelf', () => { + atomA.onMount = () => {} + let unsubA = store.sub(atomA, () => {}) + expect(getAtomState).toHaveBeenCalledTimes(1) + expect(getAtomState).nthCalledWith(1, atomA) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + jest.clearAllMocks() + unsubA() + expect(getAtomState).toHaveBeenCalledTimes(0) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + jest.clearAllMocks() + atomA.onMount = (setSelf) => { + setSelf(-1) + return () => {} + } + unsubA = store.sub(atomA, () => {}) + expect(getAtomState).toHaveBeenCalledTimes(2) + expect(getAtomState).nthCalledWith(1, atomA) + expect(getAtomState).nthCalledWith(2, atomA, atomAState) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + expect(atomWriteTrap).toHaveBeenCalledTimes(1) + expect(atomWriteTrap).toHaveBeenNthCalledWith(1, ...nthWriteParams(1, [atomA, , , -1])) + expect(nthAtomWriteGetter(1)).toHaveBeenCalledTimes(0) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledWith(atomA, -1) + jest.clearAllMocks() + unsubA() + expect(getAtomState).toHaveBeenCalledTimes(0) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + expect(atomWriteTrap).toHaveBeenCalledTimes(0) + jest.clearAllMocks() + atomA.onMount = (setSelf) => { + return () => setSelf(-1) + } + unsubA = store.sub(atomA, () => {}) + expect(getAtomState).toHaveBeenCalledTimes(1) + expect(getAtomState).nthCalledWith(1, atomA) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + expect(atomWriteTrap).toHaveBeenCalledTimes(0) + jest.clearAllMocks() + unsubA() + expect(getAtomState).toHaveBeenCalledTimes(1) + expect(getAtomState).nthCalledWith(1, atomA, atomAState) + expect(atomReadTrap).toHaveBeenCalledTimes(0) + expect(atomWriteTrap).toHaveBeenCalledTimes(1) + expect(atomWriteTrap).toHaveBeenNthCalledWith(1, ...nthWriteParams(1, [atomA, , , -1])) + expect(nthAtomWriteGetter(1)).toHaveBeenCalledTimes(0) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledWith(atomA, -1) + }) + + it('setSelf', async () => { + store.get(atomE) + await 'microtask' + expect(getAtomState).toHaveBeenCalledTimes(2) + atomState = stateMap.get(atomE)! + atomState.label = atomE.debugLabel! + expect(getAtomState).nthCalledWith(1, atomE) + expect(getAtomState).nthCalledWith(2, atomE) + + expect(atomReadTrap).toHaveBeenCalledTimes(1) + expect(atomReadTrap).toHaveBeenNthCalledWith(1, ...nthReadParams(1, [atomE])) + expect(nthAtomReadGetter(1)).toHaveBeenCalledTimes(0) + }) + + const atomF = atom(0) + atomF.debugLabel = 'atomF' + const atomG = atom( + (get) => get(atomF), + (get, set, value: number) => { + get(atomF) + set(atomF, value) + get(atomF) + }, + ) + atomG.debugLabel = 'atomG' + const atomH = atom( + (get) => get(atomG), + (get, set, value: number) => { + get(atomG) + set(atomG, value) + get(atomG) + }, + ) + atomH.debugLabel = 'atomH' + let atomFState: AtomStateWithLabel + let atomGState: AtomStateWithLabel + let atomHState: AtomStateWithLabel + it('nested atom read and write', () => { + store.get(atomH) + atomFState = stateMap.get(atomF)! + atomFState.label = atomF.debugLabel! + atomGState = stateMap.get(atomG)! + atomGState.label = atomG.debugLabel! + atomHState = stateMap.get(atomH)! + atomHState.label = atomH.debugLabel! + + expect(getAtomState).toHaveBeenCalledTimes(7) + expect(getAtomState).nthCalledWith(1, atomH) + expect(getAtomState).nthCalledWith(2, atomG, atomHState) + expect(getAtomState).nthCalledWith(3, atomF, atomGState) + expect(getAtomState).nthCalledWith(4, atomF, atomFState) + expect(getAtomState).nthCalledWith(5, atomF) + expect(getAtomState).nthCalledWith(6, atomG) + expect(getAtomState).nthCalledWith(7, atomH) + + expect(atomReadTrap).toHaveBeenCalledTimes(3) + expect(atomReadTrap).toHaveBeenNthCalledWith(1, ...nthReadParams(1, [atomH])) + expect(nthAtomReadGetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomReadGetter(1)).toHaveBeenCalledWith(atomG) + expect(atomReadTrap).toHaveBeenNthCalledWith(2, ...nthReadParams(2, [atomG])) + expect(nthAtomReadGetter(2)).toHaveBeenCalledTimes(1) + expect(nthAtomReadGetter(2)).toHaveBeenCalledWith(atomF) + expect(atomReadTrap).toHaveBeenNthCalledWith(3, ...nthReadParams(3, [atomF])) + expect(nthAtomReadGetter(3)).toHaveBeenCalledTimes(1) + expect(nthAtomReadGetter(3)).toHaveBeenCalledWith(atomF) + + jest.clearAllMocks() + store.set(atomH, 0) + expect(getAtomState).toHaveBeenCalledTimes(10) + expect(getAtomState).nthCalledWith(1, atomH) + expect(getAtomState).nthCalledWith(2, atomG, atomHState) + expect(getAtomState).nthCalledWith(3, atomF, atomGState) + expect(getAtomState).nthCalledWith(4, atomG, atomHState) + expect(getAtomState).nthCalledWith(5, atomF, atomGState) + expect(getAtomState).nthCalledWith(6, atomF, atomGState) + expect(getAtomState).nthCalledWith(7, atomF, atomFState) + expect(getAtomState).nthCalledWith(8, atomF, atomGState) + expect(getAtomState).nthCalledWith(9, atomG, atomHState) + expect(getAtomState).nthCalledWith(10, atomF, atomGState) + + expect(atomReadTrap).toHaveBeenCalledTimes(0) + expect(atomWriteTrap).toHaveBeenCalledTimes(3) + expect(atomWriteTrap).toHaveBeenNthCalledWith(1, ...nthWriteParams(1, [atomH, , , 0])) + expect(nthAtomWriteGetter(1)).toHaveBeenCalledTimes(2) + expect(nthAtomWriteGetter(1)).toHaveBeenNthCalledWith(1, atomG) + expect(nthAtomWriteGetter(1)).toHaveBeenNthCalledWith(2, atomG) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteSetter(1)).toHaveBeenCalledWith(atomG, 0) + expect(atomWriteTrap).toHaveBeenNthCalledWith(2, ...nthWriteParams(2, [atomG, , , 0])) + expect(nthAtomWriteGetter(2)).toHaveBeenCalledTimes(2) + expect(nthAtomWriteGetter(2)).toHaveBeenNthCalledWith(1, atomF) + expect(nthAtomWriteGetter(2)).toHaveBeenNthCalledWith(2, atomF) + expect(nthAtomWriteSetter(2)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteSetter(2)).toHaveBeenCalledWith(atomF, 0) + expect(atomWriteTrap).toHaveBeenNthCalledWith(3, ...nthWriteParams(3, [atomF, , , 0])) + expect(nthAtomWriteGetter(3)).toHaveBeenCalledTimes(0) + expect(nthAtomWriteSetter(3)).toHaveBeenCalledTimes(1) + expect(nthAtomWriteSetter(3)).toHaveBeenCalledWith(atomF, 0) + }) +}) diff --git a/__tests__/utils.ts b/__tests__/utils.ts index 0d210fe..7ac18df 100644 --- a/__tests__/utils.ts +++ b/__tests__/utils.ts @@ -24,14 +24,7 @@ export function clickButton(container: HTMLElement, querySelector: string) { fireEvent.click(button) } -export function getDevStore(store: Store): INTERNAL_PrdStore & INTERNAL_DevStoreRev4 { - if (!isDevStore(store)) { - throw new Error('Store is not a dev store') - } - return store -} - -export function isDevStore(store: Store): store is INTERNAL_PrdStore & INTERNAL_DevStoreRev4 { +function isDevStore(store: Store): store is INTERNAL_PrdStore & INTERNAL_DevStoreRev4 { return ( 'dev4_get_internal_weak_map' in store && 'dev4_get_mounted_atoms' in store && diff --git a/approaches/readAtomState.md b/approaches/readAtomState.md new file mode 100644 index 0000000..f962db0 --- /dev/null +++ b/approaches/readAtomState.md @@ -0,0 +1,13 @@ +## Objectives + +1. Derived atoms are copied even if they don’t depend on scoped atoms. +2. If the derived atom has already mounted, don't call onMount again. + Fixes: + +- [Scope caused atomWithObservable to be out of sync](https://github.com/jotaijs/jotai-scope/issues/36) +- [Computed atoms get needlessly triggered again](https://github.com/jotaijs/jotai-scope/issues/25) + +## Requirements + +1. Some way to get whether the atom has been mounted. +2. Some way to bypass the onMount call if the atom is already mounted. diff --git a/approaches/unstable_derive.md b/approaches/unstable_derive.md new file mode 100644 index 0000000..ced3cb0 --- /dev/null +++ b/approaches/unstable_derive.md @@ -0,0 +1,81 @@ +# Objectives + +1. Derived atoms are not copied if they don’t depend on scoped atoms. +2. When a derived atom starts depending on a scoped atom, a new atom state is created as the scoped atom state. +3. When a derived atom stops depending on a scoped atom, it must be removed from the scope state and restored to the original atom state. + a. When changing between scoped and unscoped, all subscibers must be notified. + + Fixes: + + - [Scope caused atomWithObservable to be out of sync](https://github.com/jotaijs/jotai-scope/issues/36) + - [Computed atoms get needlessly triggered again](https://github.com/jotaijs/jotai-scope/issues/25) + +# Requirements + +1. Some way to track dependencies of computed atoms not in the scope without copying them. +2. Some way to get whether the atom has been mounted. + +# Problem Statement + +A computed atom may or may not consume scoped atoms. This may also change as state changes. + +```tsx +const providerAtom = atom('unscoped') +const scopedProviderAtom = atom('scoped') +const shouldConsumeScopedAtom = atom(false) +const consumerAtom = atom((get) => { + if (get(shouldConsumeScopedAtom)) { + return get(scopedProviderAtom) + } + return get(providerAtom) +}) + +function Component() { + const value = useAtomValue(consumerAtom) + return value +} + +function App() { + const setShouldConsumeScopedAtom = useSetAtom(shouldConsumeScopedAtom) + useEffect(() => { + const timeoutId = setTimeout(setShouldConsumeScopedAtom, 1000, true) + return () => clearTimeout(timeoutId) + }, []) + + return ( + + + + ) +} +``` + +To properly handle `consumerAtom`, we need to track the dependencies of the computed atom. + +# Proxy State + +Atom state has the following shape; + +```ts +type AtomState = { + d: Map; // map of atom consumers to their epoch number + p: Set; // set of pending atom consumers + n: number; // epoch number + m?: { + l: Set<() => void>; // set of listeners + d: Set; // set of mounted atom consumers + t: Set; // set of mounted atom providers + u?: (setSelf: () => any) => (void | () => void); // unmount function + }; + v?: any; // value + e?: any; // error +}; +``` + +All computed atoms (`atom.read !== defaultRead`) will have their base atomState converted to a proxy state. The proxy state will track dependencies and notify when they change. + +0. Update all computed atoms with a proxy state in the parent store. +1. If a computer atom does not depend on any scoped atoms, remove it from the unscopedComputed set +2. If a computed atom starts depending on a scoped atom, add it to the scopedComputed set. + a. If the scoped state does not already exist, create a new scoped atom state. +3. If a computed atom stops depending on a scoped atom, remove it from the scopedComputed set. diff --git a/notes b/notes new file mode 100644 index 0000000..af17c74 --- /dev/null +++ b/notes @@ -0,0 +1,27 @@ +computed atoms are like implicit atoms but reverse +computed atoms are not copied +reverseImplicitSet is a set of computed atoms to indicate whether a computed atom should be treated as a reverse implicit +the atom is removed from the set between recomputations +by intercepting the readFn, if an atom is `get` that is either an explicit or reverse implicit, + +- then the atom is added to the reverse implicit set + +only the readFn determines if the atom is added to the reverse implicit set +intercepting the readFn and writeFn is used to get the "correct" atom +when a computed atom converts to reverse implicit, + +- its atomState is created from scratch +- this is because the atomState stores a different value for the scoped atom and can have different dependencies + +**Special Case:** on first read, when a computed atom reads a scoped atom, + +1. it is added to the reverse implicit set +1. the atomState is copied from the unscoped atomState +1. getAtomState points to the scoped atomState + +the atomStateProxy is no longer needed. + +# Implementation + +readAtomTrap: +getter: diff --git a/src/ScopeProvider/scope.ts b/src/ScopeProvider/scope.ts index 3079f41..772769c 100644 --- a/src/ScopeProvider/scope.ts +++ b/src/ScopeProvider/scope.ts @@ -83,6 +83,7 @@ export function createScope( * Returns a scoped atom from the original atom. * @param anAtom * @param implicitScope the atom is implicitly scoped in the provided scope + * - when the implicit scope is the current scope, the atom is emplaced in the implicit set and returned * @returns the scoped atom and the scope of the atom */ function getAtom(anAtom: T, implicitScope?: Scope): [T, Scope?] { @@ -152,6 +153,9 @@ export function createScope( } /** + * Makes a clone of the atom + * - replaces read with a scoped read function + * - replaces write with a scoped write function * @returns a scoped copy of the atom */ function cloneAtom(originalAtom: Atom, implicitScope?: Scope) { @@ -179,6 +183,12 @@ export function createScope( return scopedAtom } + /** + * Creates a scoped read function that intercepts the read function of the original atom + * to intercept the getter with the custom getAtom function + * @param implicitScope + * @returns + */ function createScopedRead>( read: T['read'], implicitScope?: Scope, @@ -194,6 +204,12 @@ export function createScope( } } + /** + * Creates a scoped write function that intercepts the write function of the original atom + * to intercept the getter and setter with the custom getAtom function + * @param implicitScope + * @returns + */ function createScopedWrite( write: T['write'], implicitScope?: Scope, diff --git a/src/ScopeProvider2/ScopeProvider2.tsx b/src/ScopeProvider2/ScopeProvider2.tsx new file mode 100644 index 0000000..b7656fe --- /dev/null +++ b/src/ScopeProvider2/ScopeProvider2.tsx @@ -0,0 +1,53 @@ +import { type ReactNode, useState } from 'react' +import { Provider, useStore } from '../../jotai/react' +import { type Atom } from '../../jotai/vanilla' +import { AnyAtom, AnyAtomFamily, Store } from './types' +import { createScope } from './scope' +import { isEqualSet } from './utils' + +type BaseScopeProviderProps = { + atoms?: Iterable + atomFamilies?: Iterable + debugName?: string + store?: Store + children: ReactNode +} + +export function ScopeProvider( + props: { atoms: Iterable> } & BaseScopeProviderProps, +): JSX.Element + +export function ScopeProvider( + props: { atomFamilies: Iterable } & BaseScopeProviderProps, +): JSX.Element + +export function ScopeProvider(props: BaseScopeProviderProps) { + const { atoms, atomFamilies, children, debugName, ...options } = props + const baseStore = useStore(options) + const atomSet = new Set(atoms) + const atomFamilySet = new Set(atomFamilies) + + function initialize() { + return { + scope: createScope(atomSet, atomFamilySet, baseStore, debugName), + hasChanged(current: { + baseStore: Store + atomSet: Set> + atomFamilySet: Set + }) { + return ( + current.baseStore !== baseStore || + !isEqualSet(atomSet, current.atomSet) || + !isEqualSet(atomFamilySet, current.atomFamilySet) + ) + }, + } + } + + const [{ hasChanged, scope }, setState] = useState(initialize) + if (hasChanged({ baseStore, atomSet, atomFamilySet })) { + scope.cleanup() + setState(initialize) + } + return {children} +} diff --git a/src/ScopeProvider2/mapProxy.ts b/src/ScopeProvider2/mapProxy.ts new file mode 100644 index 0000000..f3f33d9 --- /dev/null +++ b/src/ScopeProvider2/mapProxy.ts @@ -0,0 +1,53 @@ +export type MapAction = + | { + type: 'SET' + payload: { + key: K + value: V + } + value?: V | undefined + } + | { + type: 'DELETE' + payload: { + key: K + value?: undefined + } + value?: V | undefined + } + | { + type: 'CLEAR' + payload?: { + key?: undefined + value?: undefined + } + value?: undefined + } + +export class MapProxy extends Map { + constructor( + entries?: IterableIterator<[K, V]> | null, + private callback?: (action: MapAction) => void, + ) { + super(entries) + } + + set(key: K, value: V) { + this.callback?.({ + type: 'SET', + payload: { key, value }, + value: super.get(key), + }) + return super.set(key, value) + } + + delete(key: K) { + this.callback?.({ type: 'DELETE', payload: { key }, value: super.get(key) }) + return super.delete(key) + } + + clear() { + this.callback?.({ type: 'CLEAR' }) + super.clear() + } +} diff --git a/src/ScopeProvider2/scope.ts b/src/ScopeProvider2/scope.ts new file mode 100644 index 0000000..9051289 --- /dev/null +++ b/src/ScopeProvider2/scope.ts @@ -0,0 +1,246 @@ +import { atom, type Atom } from '../../jotai/vanilla' +import { MapProxy } from './mapProxy' +import type { + Scope, + AnyAtom, + AnyAtomFamily, + AnyWritableAtom, + AtomState, + NamedStore, + Store, +} from './types' +import { assertIsAtomStateWithDepListeners } from './types' +import { emplace } from './utils' + +const scopeAtom = atom(null) + +/** + * @returns a derived store that intercepts get and set calls to apply the scope + */ +export function createScope( + atoms: Set, + atomFamilies: Set, + baseStore: Store, + debugName?: string, +) { + // ================================================================================== + + /** set of explicitly scoped atoms */ + const explicit = new WeakSet() + + /** set of implicitly scoped atoms */ + const implicit = new WeakSet() + + /** set of computed atoms that that consume explicit scoped atoms */ + const computedConsumer = new WeakSet() + + // ================================================================================== + + const store = deriveStore() + const currentScope: Scope = { + /** + * Returns a scoped atom from the original atom. + * @param anAtom + * @param isFromExplicit the caller is an explicit or implicit atom + * @returns the scoped atom + */ + getAtom(anAtom, isFromExplicit = false) { + // TODO: does getAtom do anything important? + if (explicit.has(anAtom)) { + return anAtom + } + // Since any computed atom can now call getAtom, + // we need to know if the caller is an explicit or implicit atom + // in order to determine if the atom should be implicitly scoped + if (isFromExplicit) { + // dependencies of explicitly scoped atoms are implicitly scoped + // implicitly scoped atoms are only accessed by implicit and explicit scoped atoms + implicit.add(anAtom) + return anAtom + } + // TODO: do we need to clone inherited atoms? + if (parentScope) { + // inherited atoms are not copied but they can still access scoped atoms + // in the current store with the read and write traps + return parentScope.getAtom(anAtom) + } + return anAtom + }, + } + if (debugName) { + currentScope.name = `scope:${debugName}` + currentScope.toString = () => debugName + } + store.set(scopeAtom, currentScope) + const parentScope = baseStore.get(scopeAtom) + + // ---------------------------------------------------------------------------------- + + for (const anAtom of atoms) { + explicit.add(anAtom) + } + + const cleanupSet = new Set<() => void>() + function cleanupAll() { + for (const cleanup of cleanupSet) { + cleanup() + } + cleanupSet.clear() + } + + for (const atomFamily of atomFamilies) { + for (const param of atomFamily.getParams()) { + const anAtom = atomFamily(param) + explicit.add(anAtom) + } + cleanupSet.add( + atomFamily.unstable_listen(({ type, atom: anAtom }) => { + if (type === 'CREATE') { + explicit.add(anAtom) + } else if (!atoms.has(anAtom)) { + explicit.delete(anAtom) + } + }), + ) + } + + // ---------------------------------------------------------------------------------- + + function fromExplicit(anAtom: AnyAtom) { + return implicit.has(anAtom) || explicit.has(anAtom) + } + + function deriveStore() { + const derivedStore: NamedStore = baseStore.unstable_derive((baseGetAtomState) => { + /** map of scoped atoms to their atomState states */ + const scopedAtomStateMap = new WeakMap>() + + /** set of proxied atom states */ + const proxiedAtomStateSet = new WeakSet() + + return [ + function getAtomState(anAtom, originAtomState) { + if (explicit.has(anAtom)) { + return emplace(anAtom, scopedAtomStateMap, createAtomState) + } + if (implicit.has(anAtom)) { + return emplace(anAtom, scopedAtomStateMap, createAtomState) + } + // TODO: handle writable atoms + // TODO: do we need to clone the computed atom? + // TODO: do we need to doubly-link the computed atom state? + if (isComputedAtom(anAtom)) { + const baseAtomState = emplace(anAtom, proxiedAtomStateSet, () => + proxyAtomState(anAtom, baseGetAtomState(anAtom, originAtomState)), + ) + if (computedConsumer.has(anAtom)) { + return emplace(anAtom, scopedAtomStateMap, () => createAtomState(baseAtomState)) + } + } + // inherit atom state + return baseGetAtomState(anAtom, originAtomState)! + }, + function atomReadTrap(anAtom, getter, options) { + return anAtom.read( + function atomReadScopedGetter(a) { + return getter(currentScope.getAtom(a, fromExplicit(anAtom))) + }, // + options, + ) + }, + function atomWriteTrap(anAtom, getter, setter, ...args) { + return anAtom.write( + function atomWriteScopedGetter(a) { + return getter(currentScope.getAtom(a, fromExplicit(anAtom))) + }, + function atomWriteScopedSetter(a, ...v) { + return setter(currentScope.getAtom(a, fromExplicit(anAtom)), ...v) + }, + ...args, + ) + }, + ] + }) + if (debugName) { + derivedStore.name = `store:${debugName}` + } + return derivedStore + } + + /** + * @modifies {ProxyMap} atomState.d + * @modifies {Set<() => void>} atomState.l + */ + function proxyAtomState(anAtom: Atom, atomState: AtomState) { + assertIsAtomStateWithDepListeners(atomState) + atomState.s ??= new Map() + const { d, l } = emplace(currentScope, atomState.s, () => ({ + d: new Set(), + l: (action) => { + const a = action.payload?.key! + if (action.type === 'SET' && (explicit.has(a) || computedConsumer.has(a))) { + d.add(a) + } + if (action.type === 'DELETE') { + d.delete(a) + } + if (action.type === 'CLEAR') { + d.clear() + } + if (d.size === 0) { + computedConsumer.delete(anAtom) + } else { + computedConsumer.add(anAtom) + } + // TODO: handle the case when explicit atoms are added or removed + }, + })) + for (const [a, v] of atomState.d.entries()) { + l({ type: 'SET', payload: { key: a, value: v } }) + } + if (!(atomState.d instanceof MapProxy)) { + atomState.d = new MapProxy(atomState.d.entries(), function notifyListeners(action) { + for (const { l } of atomState.s.values()) { + l(action) + } + }) + } + cleanupSet.add(() => atomState.s.delete(currentScope)) + return atomState + } + + return { store, cleanup: cleanupAll } +} + +function isComputedAtom(anAtom: AnyAtom) { + return anAtom.read !== defaultRead +} + +function isWritableAtom(anAtom: AnyAtom): anAtom is AnyWritableAtom { + return 'write' in anAtom +} + +const { read: defaultRead, write: defaultWrite } = atom(null) + +/** + * creates a new atom state + * + * if atomState is provided, it will be cloned + */ +function createAtomState(atomState?: AtomState): AtomState { + const newAtomState: AtomState = { + n: 0, + ...atomState, + d: new Map(atomState?.d), + p: new Set(atomState?.p), + } + if (atomState?.m) { + newAtomState.m = { + ...atomState?.m, + l: new Set(atomState?.m.l), + d: new Set(atomState?.m.d), + t: new Set(atomState?.m.t), + } + } + return newAtomState +} diff --git a/src/ScopeProvider2/types.ts b/src/ScopeProvider2/types.ts new file mode 100644 index 0000000..071ce43 --- /dev/null +++ b/src/ScopeProvider2/types.ts @@ -0,0 +1,102 @@ +import type { getDefaultStore, WritableAtom, Atom } from '../../jotai' +import type { AtomFamily } from '../../jotai/vanilla/utils/atomFamily' +import { MapAction } from './mapProxy' + +export type Store = ReturnType + +export type NamedStore = Store & { name?: string } + +export type AnyAtom = Atom | WritableAtom + +export type AnyAtomFamily = AtomFamily + +export type AnyWritableAtom = WritableAtom + +/* =================== Stolen from jotai/store.ts ================== */ +type AnyValue = unknown +type AnyError = unknown +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. + */ + 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 + */ + 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. */ + l: Set<() => void> + /** Set of mounted atoms that the atom depends on. */ + d: Set + /** Set of mounted atoms that depends on the atom. */ + t: Set + /** Function to run when the atom is unmounted. */ + u?: OnUnmount + } + /** Atom value */ + v?: Value + /** Atom error */ + e?: AnyError +} +/* ================================================================= */ + +type DepListeners = { + /** a set of scoped atoms consumed by this atom */ + d: Set + + /** a listener to notify when the atom value changes */ + l: (action: MapAction) => void +} + +export type AtomStateWithDepListeners = AtomState & { + /** a weakmap of scopes and their dependent listeners */ + s: Map +} + +export function assertIsAtomStateWithDepListeners( + atomState: any, +): asserts atomState is AtomStateWithDepListeners { + return atomState +} + +export type Scope = { + /** + * Returns a scoped atom from the original atom. + * @param anAtom + * @param originalAtom the parent atom that called getAtom + * @returns the scoped atom and the scope of the atom + */ + getAtom: (anAtom: T, isImplicit?: boolean) => T + + /** + * @debug + */ + name?: string + + /** + * @debug + */ + toString?: () => string +} diff --git a/src/ScopeProvider2/utils.ts b/src/ScopeProvider2/utils.ts new file mode 100644 index 0000000..256f4fe --- /dev/null +++ b/src/ScopeProvider2/utils.ts @@ -0,0 +1,39 @@ +/** + * @returns true if the two sets are equal + */ +export function isEqualSet(a: Set, b: Set) { + return a === b || (a.size === b.size && Array.from(a).every((v) => b.has(v))) +} + +type MapLike = { + has?(key: K): boolean + get(key: K): V | undefined + set(key: K, value: V): void +} + +type SetLike = { + has(value: T): boolean + add(value: T): boolean +} + +/** + * emplace a key-value pair in a collection if the key is not already present + * @param key - the key to emplace + * @param map - the map to emplace the key-value pair + * @param callback - the callback to create the value. It is only called if the key is not already present. + * @returns the value associated with the key + */ +export function emplace(key: T, map: MapLike, callback: () => V): V +export function emplace(key: T, set: SetLike, callback: () => V): V +export function emplace(key: T, map: WeakMap, callback: () => V): V +export function emplace(key: T, set: WeakSet, callback: () => V): V +export function emplace(key: any, collection: any, callback: () => any) { + if (collection.has ? !collection.has(key) : !collection.get(key)) { + if (collection.add?.(key)) { + callback() + } else { + collection.set(key, callback()) + } + } + return collection.get?.(key) +}