diff --git a/.github/workflows/test-multiple-builds.yml b/.github/workflows/test-multiple-builds.yml index f81874a059..04dfc56006 100644 --- a/.github/workflows/test-multiple-builds.yml +++ b/.github/workflows/test-multiple-builds.yml @@ -30,11 +30,11 @@ jobs: - name: Patch for DEV-ONLY if: ${{ matrix.env == 'development' }} run: | - sed -i~ "s/it[a-zA-Z]*('\[PRD-ONLY\]/it.skip('/" tests/*/*.tsx + sed -i~ "s/it[a-zA-Z]*('\[PRD-ONLY\]/it.skip('/" tests/*/*.tsx tests/*/*/*.tsx - name: Patch for PRD-ONLY if: ${{ matrix.env == 'production' }} run: | - sed -i~ "s/it[a-zA-Z]*('\[DEV-ONLY\]/it.skip('/" tests/*/*.tsx + sed -i~ "s/it[a-zA-Z]*('\[DEV-ONLY\]/it.skip('/" tests/*/*.tsx tests/*/*/*.tsx - name: Patch for CJS if: ${{ matrix.build == 'cjs' }} run: | @@ -43,7 +43,7 @@ jobs: if: ${{ matrix.build == 'esm' }} run: | sed -i~ "s/\/src\(.*\)\.ts/\/dist\/esm\1.js" package.json - sed -i~ "1s/^/import.meta.env=import.meta.env||{};import.meta.env.MODE='${NODE_ENV}';/" tests/*/*.tsx + sed -i~ "1s/^/import.meta.env=import.meta.env||{};import.meta.env.MODE='${NODE_ENV}';/" tests/*/*.tsx tests/*/*/*.tsx env: NODE_ENV: ${{ matrix.env }} - name: Patch for UMD/SystemJS diff --git a/.github/workflows/test-multiple-versions.yml b/.github/workflows/test-multiple-versions.yml index 3167aaaee9..e7cce28c1a 100644 --- a/.github/workflows/test-multiple-versions.yml +++ b/.github/workflows/test-multiple-versions.yml @@ -63,9 +63,13 @@ jobs: - name: Patch for React 16 if: ${{ startsWith(matrix.react, '16.') }} run: | - sed -i~ '1s/^/import React from "react";/' tests/*/*.tsx + sed -i~ '1s/^/import React from "react";/' tests/*/*.tsx src/*/*.tsx sed -i~ 's/automatic/classic/' babel.config.js sed -i~ 's/automatic/classic/' .swcrc + - name: Ignore Async API tests for Older React + if: ${{ matrix.react == '16.8.6' }} || ${{ matrix.mode != 'NORMAL' }} + run: | + rm -r tests/vanilla tests/react - name: Test ${{ matrix.react }} ${{ matrix.mode }} run: | yarn add -D react@${{ matrix.react }} react-dom@${{ matrix.react }} diff --git a/.github/workflows/test-old-typescript.yml b/.github/workflows/test-old-typescript.yml index 58dd191f13..375e1c0088 100644 --- a/.github/workflows/test-old-typescript.yml +++ b/.github/workflows/test-old-typescript.yml @@ -36,7 +36,7 @@ jobs: - run: yarn build - name: Patch for Old TS run: | - sed -i~ "s/\/\/ @ts-expect-error.*\[LATEST-TS-ONLY\]//" tests/*/*.tsx + sed -i~ "s/\/\/ @ts-expect-error.*\[LATEST-TS-ONLY\]//" tests/*/*.tsx tests/*/*/*.tsx sed -i~ "s/\"exactOptionalPropertyTypes\": true,//" tsconfig.json sed -i~ "s/\"jotai\": \[\"\.\/src\/index\.ts\"\],/\"jotai\": [\".\/dist\/ts3.4\/index.d.ts\"],/" tsconfig.json sed -i~ "s/\"jotai\/\*\": \[\"\.\/src\/\*\.ts\"\]/\"jotai\/*\": [\".\/dist\/ts3.4\/*.d.ts\"]/" tsconfig.json @@ -44,14 +44,18 @@ jobs: - name: Patch for Older TS if: ${{ matrix.typescript == '4.2.3' || matrix.typescript == '4.1.5' || matrix.typescript == '4.0.5' || startsWith(matrix.typescript, '3.') }} run: | - sed -i~ '1s/^/import React from "react";/' tests/*/*.tsx + sed -i~ '1s/^/import React from "react";/' tests/*/*.tsx tests/*/*/*.tsx sed -i~ "s/\"jsx\": \"react-jsx\",/\"jsx\": \"react\",/" tsconfig.json sed -i~ "s/\"noUncheckedIndexedAccess\": true,//" tsconfig.json - sed -i~ "s/^import type /import /" tests/*/*.tsx + sed -i~ "s/^import type /import /" tests/*/*.tsx tests/*/*/*.tsx sed -i~ "s/^.* from '\.\/utils\/waitForAll';//" dist/ts3.4/utils.d.ts yarn json -I -f package.json -e "this.resolutions['@types/jest']='26.0.14'; this.resolutions['pretty-format']='25.5.0'; this.resolutions['@types/prettier']='2.4.2';" yarn add -D @types/jest@26.0.14 pretty-format@25.5.0 @types/prettier@2.4.2 - rm -r tests/query tests/urql tests/optics tests/xstate tests/valtio tests/zustand tests/utils/atomWithObservable.* tests/utils/waitForAll.* + rm -r tests/query tests/urql tests/optics tests/xstate tests/valtio tests/zustand tests/utils/atomWithObservable.* tests/utils/waitForAll.* tests/vanilla/utils/atomWithObservable.* + - name: Ignore Async API tests for Older TS + if: ${{ matrix.typescript == '3.7.5' }} + run: | + rm -r tests/vanilla tests/react - name: Test ${{ matrix.typescript }} run: | yarn add -D typescript@${{ matrix.typescript }} diff --git a/docs/guides/migrating-to-v2-api.mdx b/docs/guides/migrating-to-v2-api.mdx new file mode 100644 index 0000000000..047e19def5 --- /dev/null +++ b/docs/guides/migrating-to-v2-api.mdx @@ -0,0 +1,222 @@ +--- +title: Migrating to Jotai v2 API +description: New "Async" API +nav: 3.13 +--- + +RFC: https://github.com/pmndrs/jotai/discussions/1514 + +Jotai v1 is released at June 2022, and there has been various feedbacks. +React also proposes first-class support for promises. +Jotai v2 will have a new API. + +Unfortunately, there are some breaking changes along with new features. + +## What are new features + +### Vanilla library + +Jotai comes with vanilla (non-React) functions +and React functions separately. + +### Store API + +Jotai exposes store interface so that you can directly manipulate atom values. + +```js +import { createStore } from 'jotai/vanilla' + +const store = createStore() +store.set(fooAtom, 'foo') +``` + +You can also create your own React Context to pass a store. + +### More flexible atom `write` function + +The write function can accept multiple arguments, +and return a value. + +```js +atom( + (get) => get(...), + (get, set, arg1, arg2, ...) => { + ... + return someValue + } +) +``` + +## What are breaking + +### Import statements + +The new API is provided from different entry points: + +- `jotai/vanilla` +- `jotai/vanilla/utils` +- `jotai/react` +- `jotai/react/devtools` +- `jotai/react/utils` + +```js +import { atom } from 'jotai/vanilla' +import { useAtom } from 'jotai/react' +``` + +These new entry points are added in v1.11.0 as pre-release, which will continue to work after v2.0.0 release. + +In v2.0.0, they are the defaults and old entry points simply refer to the new ones. + +```js +// v2 +import { atom } from 'jotai' // is same as 'jotai/vanilla' +import { useAtom } from 'jotai' // is same as 'jotai/react' +``` + +### Async atoms are no longer special + +Async atoms are just normal atoms with promise values. +Atoms getter functions don't resolve promises. +On the other hand, `useAtom` hook continues to resolve promises. + +### Writable atom type is changed (TypeScript only) + +```ts +// Old +WritableAtom> + +// New +WritableAtom +``` + +In general, we should avoid using `WritableAtom` type directly. + +### Some functions are dropped + +- Provider's `initialValues` prop is removed, because `store` is more flexible. +- Provider's scope props is removed, because you can create own context. +- `abortableAtom` util is removed, becuase the feature is included by default +- `waitForAll` util is removed, because `Promise.all` just works + +## Migration guides + +### Async atoms + +`get` function for read function of async atoms +doesn't resolve promises, so you have to put `await`. + +In short, the change is something like the following. +(If you are TypeScript users, types will tell where to changes.) + +#### Previous API + +```js +const asyncAtom = atom(async () => 'hello') +const derivedAtom = atom((get) => get(asyncAtom).toUppercase()) +``` + +#### New API + +```js +const asyncAtom = atom(async () => 'hello') +const derivedAtom = atom(async (get) => (await get(asyncAtom)).toUppercase()) +``` + +### Provider's `initialValues` prop + +#### Previous API + +```jsx +const countAtom = atom(0) + + + ... + +``` + +#### New API + +```jsx +const countAtom = atom(0) +const store = createStore() +store.set(countAtom, 1) + + + ... + +``` + +### Provider's `scope` prop + +#### Previous API + +```jsx +const myScope = Symbol() + + // Parent component + + ... + + + // Child component + useAtom(..., myScope) +``` + +#### New API + +```jsx +const MyContext = createContext() +const store = createStore() + + // Parent component + + ... + + + // Child Component + const store = useContext(MyContext) + useAtom(..., { store }) +``` + +### `abortableAtom` util + +You no longer need the previous `abortableAtom` util, +because it's now supported with the normal `atom`. + +#### Previous API + +```js +const asyncAtom = abortableAtom(async (get, { signal }) => { + ... +} +``` + +#### New API + +```js +const asyncAtom = atom(async (get, { signal }) => { + ... +} +``` + +### `waitForAll` util + +You no longer need the previous `waitForAll` util, +because we can use native Promise APIs. + +#### Previous API + +```js +const allAtom = waitForAll([fooAtom, barAtom]) +``` + +#### New API + +```js +const allAtom = atom((get) => Promise.all([get(fooAtom), get(barAtom)])) +``` + +## Some other changes + +- `atomWithStorage` util's `delayInit` is removed as being default. diff --git a/package.json b/package.json index ef3f5fdb7d..42ad44926b 100644 --- a/package.json +++ b/package.json @@ -26,122 +26,14 @@ "module": "./esm/index.js", "default": "./index.js" }, - "./utils": { + "./*": { "import": { - "types": "./esm/utils.d.mts", - "default": "./esm/utils.mjs" + "types": "./esm/*.d.mts", + "default": "./esm/*.mjs" }, - "types": "./utils.d.ts", - "module": "./esm/utils.js", - "default": "./utils.js" - }, - "./devtools": { - "import": { - "types": "./esm/devtools.d.mts", - "default": "./esm/devtools.mjs" - }, - "types": "./devtools.d.ts", - "module": "./esm/devtools.js", - "default": "./devtools.js" - }, - "./immer": { - "import": { - "types": "./esm/immer.d.mts", - "default": "./esm/immer.mjs" - }, - "types": "./immer.d.ts", - "module": "./esm/immer.js", - "default": "./immer.js" - }, - "./optics": { - "import": { - "types": "./esm/optics.d.mts", - "default": "./esm/optics.mjs" - }, - "types": "./optics.d.ts", - "module": "./esm/optics.js", - "default": "./optics.js" - }, - "./query": { - "import": { - "types": "./esm/query.d.mts", - "default": "./esm/query.mjs" - }, - "types": "./query.d.ts", - "module": "./esm/query.js", - "default": "./query.js" - }, - "./xstate": { - "import": { - "types": "./esm/xstate.d.mts", - "default": "./esm/xstate.mjs" - }, - "types": "./xstate.d.ts", - "module": "./esm/xstate.js", - "default": "./xstate.js" - }, - "./valtio": { - "import": { - "types": "./esm/valtio.d.mts", - "default": "./esm/valtio.mjs" - }, - "types": "./valtio.d.ts", - "module": "./esm/valtio.js", - "default": "./valtio.js" - }, - "./zustand": { - "import": { - "types": "./esm/zustand.d.mts", - "default": "./esm/zustand.mjs" - }, - "types": "./zustand.d.ts", - "module": "./esm/zustand.js", - "default": "./zustand.js" - }, - "./redux": { - "import": { - "types": "./esm/redux.d.mts", - "default": "./esm/redux.mjs" - }, - "types": "./redux.d.ts", - "module": "./esm/redux.js", - "default": "./redux.js" - }, - "./urql": { - "import": { - "types": "./esm/urql.d.mts", - "default": "./esm/urql.mjs" - }, - "types": "./urql.d.ts", - "module": "./esm/urql.js", - "default": "./urql.js" - }, - "./babel/plugin-debug-label": { - "import": { - "types": "./esm/babel/plugin-debug-label.d.mts", - "default": "./esm/babel/plugin-debug-label.mjs" - }, - "types": "./babel/plugin-debug-label.d.ts", - "module": "./esm/babel/plugin-debug-label.js", - "default": "./babel/plugin-debug-label.js" - }, - "./babel/plugin-react-refresh": { - "import": { - "types": "./esm/babel/plugin-react-refresh.d.mts", - "default": "./esm/babel/plugin-react-refresh.mjs" - }, - "types": "./babel/plugin-react-refresh.d.ts", - "module": "./esm/babel/plugin-react-refresh.js", - "default": "./babel/plugin-react-refresh.js" - }, - "./babel/preset": { - "import": { - "types": "./esm/babel/preset.d.mts", - "default": "./esm/babel/preset.mjs" - }, - "types": "./babel/preset.d.ts", - "module": "./esm/babel/preset.js", - "default": "./babel/preset.js" + "types": "./*.d.ts", + "module": "./esm/*.js", + "default": "./*.js" } }, "files": [ @@ -166,6 +58,11 @@ "build:babel:plugin-debug-label": "rollup -c --config-babel_plugin-debug-label", "build:babel:plugin-react-refresh": "rollup -c --config-babel_plugin-react-refresh", "build:babel:preset": "rollup -c --config-babel_preset", + "build:vanilla": "rollup -c --config-vanilla", + "build:vanilla:utils": "rollup -c --config-vanilla_utils", + "build:react": "rollup -c --config-react", + "build:react:utils": "rollup -c --config-react_utils", + "build:react:devtools": "rollup -c --config-react_devtools", "postbuild": "yarn copy && yarn patch-ts3.4 && yarn patch-esm-ts && yarn patch-readme", "prettier": "prettier '*.{js,json,md}' '{src,tests,benchmarks,docs}/**/*.{ts,tsx,md,mdx}' --write", "prettier:ci": "prettier '*.{js,json,md}' '{src,tests,benchmarks,docs}/**/*.{ts,tsx,md,mdx}' --list-different", @@ -182,7 +79,7 @@ "patch-readme": "shx sed -i 's/.*dark mode.*//' dist/readme.md" }, "engines": { - "node": ">=12.7.0" + "node": ">=12.20.0" }, "prettier": { "semi": false, diff --git a/src/core/atom.ts b/src/core/atom.ts index ab60e3324c..98f695ad5c 100644 --- a/src/core/atom.ts +++ b/src/core/atom.ts @@ -1,3 +1,5 @@ +import { atom as vanillaAtom } from 'jotai/vanilla' + type Getter = { (atom: Atom>): Value (atom: Atom>): Value @@ -74,8 +76,6 @@ type SetStateAction = Value | ((prev: Value) => Value) export type PrimitiveAtom = WritableAtom> -let keyCount = 0 // global key count for all atoms - // writable derived atom export function atom = void>( read: Read, @@ -99,24 +99,6 @@ export function atom( initialValue: Value ): PrimitiveAtom & WithInitialValue -export function atom>( - read: Value | Read, - write?: Write -) { - const key = `atom${++keyCount}` - const config = { - toString: () => key, - } as WritableAtom & { init?: Value } - if (typeof read === 'function') { - config.read = read as Read - } else { - config.init = read - config.read = (get) => get(config) - config.write = (get, set, update) => - set(config, typeof update === 'function' ? update(get(config)) : update) - } - if (write) { - config.write = write - } - return config +export function atom(read: any, write?: any) { + return vanillaAtom(read, write) as any } diff --git a/src/react.ts b/src/react.ts new file mode 100644 index 0000000000..271a4729ba --- /dev/null +++ b/src/react.ts @@ -0,0 +1,8 @@ +/** + * These APIs are still unstable. + * See: https://github.com/pmndrs/jotai/discussions/1514 + */ +export { Provider, useStore } from './react/Provider' +export { useAtomValue } from './react/useAtomValue' +export { useSetAtom } from './react/useSetAtom' +export { useAtom } from './react/useAtom' diff --git a/src/react/Provider.tsx b/src/react/Provider.tsx new file mode 100644 index 0000000000..bb93fc5a52 --- /dev/null +++ b/src/react/Provider.tsx @@ -0,0 +1,34 @@ +import { createContext, useContext, useRef } from 'react' +import type { ReactNode } from 'react' +import { createStore, getDefaultStore } from 'jotai/vanilla' + +type Store = ReturnType + +const StoreContext = createContext(undefined) + +type Options = { + store?: Store +} + +export const useStore = (options?: Options) => { + const store = useContext(StoreContext) + return options?.store || store || getDefaultStore() +} + +export const Provider = ({ + children, + store, +}: { + children?: ReactNode + store?: Store +}) => { + const storeRef = useRef() + if (!store && !storeRef.current) { + storeRef.current = createStore() + } + return ( + + {children} + + ) +} diff --git a/src/react/devtools.ts b/src/react/devtools.ts new file mode 100644 index 0000000000..53110d7db2 --- /dev/null +++ b/src/react/devtools.ts @@ -0,0 +1,9 @@ +/** + * These APIs are still unstable. + * See: https://github.com/pmndrs/jotai/discussions/1514 + */ +export { useAtomsDebugValue } from './devtools/useAtomsDebugValue' +export { useAtomDevtools } from './devtools/useAtomDevtools' +export { useAtomsSnapshot } from './devtools/useAtomsSnapshot' +export { useGotoAtomsSnapshot } from './devtools/useGotoAtomsSnapshot' +export { useAtomsDevtools } from './devtools/useAtomsDevtools' diff --git a/src/react/devtools/types.ts b/src/react/devtools/types.ts new file mode 100644 index 0000000000..a7e3cb0e11 --- /dev/null +++ b/src/react/devtools/types.ts @@ -0,0 +1,9 @@ +import type {} from '@redux-devtools/extension' + +// FIXME https://github.com/reduxjs/redux-devtools/issues/1097 +// This is an INTERNAL type alias. +export type Message = { + type: string + payload?: any + state?: any +} diff --git a/src/react/devtools/useAtomDevtools.ts b/src/react/devtools/useAtomDevtools.ts new file mode 100644 index 0000000000..eacfa23c21 --- /dev/null +++ b/src/react/devtools/useAtomDevtools.ts @@ -0,0 +1,130 @@ +import { useEffect, useRef } from 'react' +import { useAtom } from 'jotai/react' +import type { Atom, WritableAtom } from 'jotai/vanilla' +import { Message } from './types' + +type DevtoolOptions = { + name?: string + enabled?: boolean +} + +export function useAtomDevtools( + anAtom: WritableAtom | Atom, + options?: DevtoolOptions +): void { + const { enabled, name } = options || {} + + let extension: typeof window['__REDUX_DEVTOOLS_EXTENSION__'] | false + + try { + extension = (enabled ?? __DEV__) && window.__REDUX_DEVTOOLS_EXTENSION__ + } catch { + // ignored + } + + if (!extension) { + if (__DEV__ && enabled) { + console.warn('Please install/enable Redux devtools extension') + } + } + + const [value, setValue] = useAtom(anAtom) + + const lastValue = useRef(value) + const isTimeTraveling = useRef(false) + const devtools = useRef< + ReturnType< + NonNullable['connect'] + > & { + shouldInit?: boolean + } + >() + + const atomName = name || anAtom.debugLabel || anAtom.toString() + + useEffect(() => { + if (!extension) { + return + } + const setValueIfWritable = (value: Value) => { + if (typeof setValue === 'function') { + ;(setValue as (value: Value) => void)(value) + return + } + console.warn( + '[Warn] you cannot do write operations (Time-travelling, etc) in read-only atoms\n', + anAtom + ) + } + + devtools.current = extension.connect({ name: atomName }) + + const unsubscribe = ( + devtools.current as unknown as { + // FIXME https://github.com/reduxjs/redux-devtools/issues/1097 + subscribe: ( + listener: (message: Message) => void + ) => (() => void) | undefined + } + ).subscribe((message) => { + if (message.type === 'ACTION' && message.payload) { + try { + setValueIfWritable(JSON.parse(message.payload)) + } catch (e) { + console.error( + 'please dispatch a serializable value that JSON.parse() support\n', + e + ) + } + } else if (message.type === 'DISPATCH' && message.state) { + if ( + message.payload?.type === 'JUMP_TO_ACTION' || + message.payload?.type === 'JUMP_TO_STATE' + ) { + isTimeTraveling.current = true + + setValueIfWritable(JSON.parse(message.state)) + } + } else if ( + message.type === 'DISPATCH' && + message.payload?.type === 'COMMIT' + ) { + devtools.current?.init(lastValue.current) + } else if ( + message.type === 'DISPATCH' && + message.payload?.type === 'IMPORT_STATE' + ) { + const computedStates = + message.payload.nextLiftedState?.computedStates || [] + + computedStates.forEach(({ state }: { state: Value }, index: number) => { + if (index === 0) { + devtools.current?.init(state) + } else { + setValueIfWritable(state) + } + }) + } + }) + devtools.current.shouldInit = true + return unsubscribe + }, [anAtom, extension, atomName, setValue]) + + useEffect(() => { + if (!devtools.current) { + return + } + lastValue.current = value + if (devtools.current.shouldInit) { + devtools.current.init(value) + devtools.current.shouldInit = false + } else if (isTimeTraveling.current) { + isTimeTraveling.current = false + } else { + devtools.current.send( + `${atomName} - ${new Date().toLocaleString()}` as any, + value + ) + } + }, [anAtom, extension, atomName, value]) +} diff --git a/src/react/devtools/useAtomsDebugValue.ts b/src/react/devtools/useAtomsDebugValue.ts new file mode 100644 index 0000000000..fd81d97b5d --- /dev/null +++ b/src/react/devtools/useAtomsDebugValue.ts @@ -0,0 +1,57 @@ +import { useDebugValue, useEffect, useState } from 'react' +import { useStore } from 'jotai/react' +import type { Atom } from 'jotai/vanilla' + +type Store = ReturnType +type AtomState = NonNullable< + ReturnType> +> + +const atomToPrintable = (atom: Atom) => + atom.debugLabel || atom.toString() + +const stateToPrintable = ([store, atoms]: [Store, Atom[]]) => + Object.fromEntries( + atoms.flatMap((atom) => { + const mounted = store.dev_get_mounted?.(atom) + if (!mounted) { + return [] + } + const dependents = mounted.t + const atomState = store.dev_get_atom_state?.(atom) || ({} as AtomState) + return [ + [ + atomToPrintable(atom), + { + ...('e' in atomState && { error: atomState.e }), + ...('v' in atomState && { value: atomState.v }), + dependents: Array.from(dependents).map(atomToPrintable), + }, + ], + ] + }) + ) + +type Options = Parameters[0] & { + enabled?: boolean +} + +// We keep a reference to the atoms, +// so atoms aren't garbage collected by the WeakMap of mounted atoms +export const useAtomsDebugValue = (options?: Options) => { + const enabled = options?.enabled ?? __DEV__ + const store = useStore(options) + const [atoms, setAtoms] = useState[]>([]) + useEffect(() => { + if (!enabled) { + return + } + const callback = () => { + setAtoms(Array.from(store.dev_get_mounted_atoms?.() || [])) + } + const unsubscribe = store.dev_subscribe_state?.(callback) + callback() + return unsubscribe + }, [enabled, store]) + useDebugValue([store, atoms], stateToPrintable) +} diff --git a/src/react/devtools/useAtomsDevtools.ts b/src/react/devtools/useAtomsDevtools.ts new file mode 100644 index 0000000000..23b0f8c605 --- /dev/null +++ b/src/react/devtools/useAtomsDevtools.ts @@ -0,0 +1,151 @@ +import { useEffect, useRef } from 'react' +import type { Atom } from 'jotai/vanilla' +import { Message } from './types' +import { useAtomsSnapshot } from './useAtomsSnapshot' +import { useGotoAtomsSnapshot } from './useGotoAtomsSnapshot' + +type AnyAtomValue = unknown +type AnyAtom = Atom +type AtomsValues = Map // immutable +type AtomsDependents = Map> // immutable +type AtomsSnapshot = Readonly<{ + values: AtomsValues + dependents: AtomsDependents +}> + +const atomToPrintable = (atom: AnyAtom) => + atom.debugLabel ? `${atom}:${atom.debugLabel}` : `${atom}` + +const getDevtoolsState = (atomsSnapshot: AtomsSnapshot) => { + const values: Record = {} + atomsSnapshot.values.forEach((v, atom) => { + values[atomToPrintable(atom)] = v + }) + const dependents: Record = {} + atomsSnapshot.dependents.forEach((d, atom) => { + dependents[atomToPrintable(atom)] = Array.from(d).map(atomToPrintable) + }) + return { + values, + dependents, + } +} + +type DevtoolsOptions = { + enabled?: boolean +} + +export function useAtomsDevtools( + name: string, + options?: DevtoolsOptions +): void { + const { enabled } = options || {} + + let extension: typeof window['__REDUX_DEVTOOLS_EXTENSION__'] | false + + try { + extension = (enabled ?? __DEV__) && window.__REDUX_DEVTOOLS_EXTENSION__ + } catch { + // ignored + } + + if (!extension) { + if (__DEV__ && enabled) { + console.warn('Please install/enable Redux devtools extension') + } + } + + // This an exception, we don't usually use utils in themselves! + const atomsSnapshot = useAtomsSnapshot() + const goToSnapshot = useGotoAtomsSnapshot() + + const isTimeTraveling = useRef(false) + const isRecording = useRef(true) + const devtools = useRef< + ReturnType< + NonNullable['connect'] + > & { + shouldInit?: boolean + } + >() + + const snapshots = useRef([]) + + useEffect(() => { + if (!extension) { + return + } + const getSnapshotAt = (index = snapshots.current.length - 1) => { + // index 0 is @@INIT, so we need to return the next action (0) + const snapshot = snapshots.current[index >= 0 ? index : 0] + if (!snapshot) { + throw new Error('snaphost index out of bounds') + } + return snapshot + } + const connection = extension.connect({ name }) + + const devtoolsUnsubscribe = ( + connection as unknown as { + // FIXME https://github.com/reduxjs/redux-devtools/issues/1097 + subscribe: ( + listener: (message: Message) => void + ) => (() => void) | undefined + } + ).subscribe((message) => { + switch (message.type) { + case 'DISPATCH': + switch (message.payload?.type) { + case 'RESET': + // TODO + break + + case 'COMMIT': + connection.init(getDevtoolsState(getSnapshotAt())) + snapshots.current = [] + break + + case 'JUMP_TO_ACTION': + case 'JUMP_TO_STATE': + isTimeTraveling.current = true + goToSnapshot(getSnapshotAt(message.payload.actionId - 1)) + break + + case 'PAUSE_RECORDING': + isRecording.current = !isRecording.current + break + } + } + }) + + devtools.current = connection + devtools.current.shouldInit = true + return () => { + ;(extension as any).disconnect() + devtoolsUnsubscribe?.() + } + }, [extension, goToSnapshot, name]) + + useEffect(() => { + if (!devtools.current) { + return + } + if (devtools.current.shouldInit) { + devtools.current.init(undefined) + devtools.current.shouldInit = false + return + } + if (isTimeTraveling.current) { + isTimeTraveling.current = false + } else if (isRecording.current) { + snapshots.current.push(atomsSnapshot) + devtools.current.send( + { + type: `${snapshots.current.length}`, + updatedAt: new Date().toLocaleString(), + } as any, + getDevtoolsState(atomsSnapshot) + ) + } + }, [atomsSnapshot]) +} diff --git a/src/react/devtools/useAtomsSnapshot.ts b/src/react/devtools/useAtomsSnapshot.ts new file mode 100644 index 0000000000..9404a8461d --- /dev/null +++ b/src/react/devtools/useAtomsSnapshot.ts @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react' +import { useStore } from 'jotai/react' +import type { Atom } from 'jotai/vanilla' + +type Options = Parameters[0] +type AnyAtomValue = unknown +type AnyAtom = Atom +type AtomsValues = Map // immutable +type AtomsDependents = Map> // immutable +type AtomsSnapshot = Readonly<{ + values: AtomsValues + dependents: AtomsDependents +}> + +const isEqualAtomsValues = (left: AtomsValues, right: AtomsValues) => + left.size === right.size && + Array.from(left).every(([left, v]) => Object.is(right.get(left), v)) + +const isEqualAtomsDependents = ( + left: AtomsDependents, + right: AtomsDependents +) => + left.size === right.size && + Array.from(left).every(([a, dLeft]) => { + const dRight = right.get(a) + return ( + dRight && + dLeft.size === dRight.size && + Array.from(dLeft).every((d) => dRight.has(d)) + ) + }) + +export function useAtomsSnapshot(options?: Options): AtomsSnapshot { + const store = useStore(options) + + const [atomsSnapshot, setAtomsSnapshot] = useState(() => ({ + values: new Map(), + dependents: new Map(), + })) + + useEffect(() => { + if (!store.dev_subscribe_state) return + + let prevValues: AtomsValues = new Map() + let prevDependents: AtomsDependents = new Map() + const callback = () => { + const values: AtomsValues = new Map() + const dependents: AtomsDependents = new Map() + for (const atom of store.dev_get_mounted_atoms() || []) { + const atomState = store.dev_get_atom_state(atom) + if (atomState) { + if ('v' in atomState) { + values.set(atom, atomState.v) + } + } + const mounted = store.dev_get_mounted(atom) + if (mounted) { + dependents.set(atom, mounted.t) + } + } + if ( + isEqualAtomsValues(prevValues, values) && + isEqualAtomsDependents(prevDependents, dependents) + ) { + // not changed + return + } + prevValues = values + prevDependents = dependents + setAtomsSnapshot({ values, dependents }) + } + const unsubscribe = store.dev_subscribe_state(callback) + callback() + return unsubscribe + }, [store]) + + return atomsSnapshot +} diff --git a/src/react/devtools/useGotoAtomsSnapshot.ts b/src/react/devtools/useGotoAtomsSnapshot.ts new file mode 100644 index 0000000000..9569f7e4e5 --- /dev/null +++ b/src/react/devtools/useGotoAtomsSnapshot.ts @@ -0,0 +1,25 @@ +import { useCallback } from 'react' +import { useStore } from 'jotai/react' +import type { Atom } from 'jotai/vanilla' + +type Options = Parameters[0] +type AnyAtomValue = unknown +type AnyAtom = Atom +type AtomsValues = Map // immutable +type AtomsDependents = Map> // immutable +type AtomsSnapshot = Readonly<{ + values: AtomsValues + dependents: AtomsDependents +}> + +export function useGotoAtomsSnapshot(options?: Options) { + const store = useStore(options) + return useCallback( + (snapshot: AtomsSnapshot) => { + if (store.dev_subscribe_state) { + store.res(snapshot.values) + } + }, + [store] + ) +} diff --git a/src/react/useAtom.ts b/src/react/useAtom.ts new file mode 100644 index 0000000000..342951b930 --- /dev/null +++ b/src/react/useAtom.ts @@ -0,0 +1,49 @@ +import type { + Atom, + ExtractAtomArgs, + ExtractAtomResult, + ExtractAtomValue, + WritableAtom, +} from 'jotai/vanilla' +import { useAtomValue } from './useAtomValue' +import { useSetAtom } from './useSetAtom' + +type SetAtom = (...args: Args) => Result + +type Options = Parameters[1] + +export function useAtom( + atom: WritableAtom, + options?: Options +): [Awaited, SetAtom] + +export function useAtom( + atom: Atom, + options?: Options +): [Awaited, never] + +export function useAtom< + AtomType extends WritableAtom +>( + atom: AtomType, + options?: Options +): [ + Awaited>, + SetAtom, ExtractAtomResult> +] + +export function useAtom>( + atom: AtomType, + options?: Options +): [Awaited>, never] + +export function useAtom( + atom: Atom | WritableAtom, + options?: Options +) { + return [ + useAtomValue(atom, options), + // We do wrong type assertion here, which results in throwing an error. + useSetAtom(atom as WritableAtom, options), + ] +} diff --git a/src/react/useAtomValue.ts b/src/react/useAtomValue.ts new file mode 100644 index 0000000000..28fcf54b93 --- /dev/null +++ b/src/react/useAtomValue.ts @@ -0,0 +1,103 @@ +/// + +import ReactExports, { useDebugValue, useEffect, useReducer } from 'react' +import type { ReducerWithoutAction } from 'react' +import type { Atom, ExtractAtomValue } from 'jotai/vanilla' +import { useStore } from './Provider' + +type Store = ReturnType + +const isPromise = (x: unknown): x is Promise => x instanceof Promise + +const use = + ReactExports.use || + (( + promise: Promise & { + status?: 'pending' | 'fulfilled' | 'rejected' + value?: T + reason?: unknown + } + ): T => { + if (promise.status === 'pending') { + throw promise + } else if (promise.status === 'fulfilled') { + return promise.value as T + } else if (promise.status === 'rejected') { + throw promise.reason + } else { + promise.status = 'pending' + promise.then( + (v) => { + promise.status = 'fulfilled' + promise.value = v + }, + (e) => { + promise.status = 'rejected' + promise.reason = e + } + ) + throw promise + } + }) + +type Options = { + store?: Store + delay?: number +} + +export function useAtomValue( + atom: Atom, + options?: Options +): Awaited + +export function useAtomValue>( + atom: AtomType, + options?: Options +): Awaited> + +export function useAtomValue(atom: Atom, options?: Options) { + const store = useStore(options) + + const [[valueFromReducer, storeFromReducer, atomFromReducer], rerender] = + useReducer< + ReducerWithoutAction, + undefined + >( + (prev) => { + const nextValue = store.get(atom) + if ( + Object.is(prev[0], nextValue) && + prev[1] === store && + prev[2] === atom + ) { + return prev + } + return [nextValue, store, atom] + }, + undefined, + () => [store.get(atom), store, atom] + ) + + let value = valueFromReducer + if (storeFromReducer !== store || atomFromReducer !== atom) { + rerender() + value = store.get(atom) + } + + const delay = options?.delay + useEffect(() => { + const unsub = store.sub(atom, () => { + if (typeof delay === 'number') { + // delay rerendering to wait a promise possibly to resolve + setTimeout(rerender, delay) + return + } + rerender() + }) + rerender() + return unsub + }, [store, atom, delay]) + + useDebugValue(value) + return isPromise(value) ? use(value) : (value as Awaited) +} diff --git a/src/react/useSetAtom.ts b/src/react/useSetAtom.ts new file mode 100644 index 0000000000..4b721e350f --- /dev/null +++ b/src/react/useSetAtom.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react' +import type { + ExtractAtomArgs, + ExtractAtomResult, + WritableAtom, +} from 'jotai/vanilla' +import { useStore } from './Provider' + +type SetAtom = (...args: Args) => Result +type Store = ReturnType + +type Options = { + store?: Store +} + +export function useSetAtom( + atom: WritableAtom, + options?: Options +): SetAtom + +export function useSetAtom< + AtomType extends WritableAtom +>( + atom: AtomType, + options?: Options +): SetAtom, ExtractAtomResult> + +export function useSetAtom( + atom: WritableAtom, + options?: Options +) { + const store = useStore(options) + const setAtom = useCallback( + (...args: Args) => { + if (__DEV__ && !('write' in atom)) { + // useAtom can pass non writable atom with wrong type assertion, + // so we should check here. + throw new Error('not writable atom') + } + return store.set(atom, ...args) + }, + [store, atom] + ) + return setAtom +} diff --git a/src/react/utils.ts b/src/react/utils.ts new file mode 100644 index 0000000000..3e707565aa --- /dev/null +++ b/src/react/utils.ts @@ -0,0 +1,8 @@ +/** + * These APIs are still unstable. + * See: https://github.com/pmndrs/jotai/discussions/1514 + */ +export { useResetAtom } from './utils/useResetAtom' +export { useReducerAtom } from './utils/useReducerAtom' +export { useAtomCallback } from './utils/useAtomCallback' +export { useHydrateAtoms } from './utils/useHydrateAtoms' diff --git a/src/react/utils/useAtomCallback.ts b/src/react/utils/useAtomCallback.ts new file mode 100644 index 0000000000..6cef6b79ef --- /dev/null +++ b/src/react/utils/useAtomCallback.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react' +import { useSetAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' +import type { Getter, Setter } from 'jotai/vanilla' + +type Options = Parameters[1] + +export function useAtomCallback( + callback: (get: Getter, set: Setter, ...arg: Args) => Result, + options?: Options +): (...args: Args) => Result { + const anAtom = useMemo( + () => atom(null, (get, set, ...args: Args) => callback(get, set, ...args)), + [callback] + ) + return useSetAtom(anAtom, options) +} diff --git a/src/react/utils/useHydrateAtoms.ts b/src/react/utils/useHydrateAtoms.ts new file mode 100644 index 0000000000..c600c8ab44 --- /dev/null +++ b/src/react/utils/useHydrateAtoms.ts @@ -0,0 +1,36 @@ +import { useStore } from 'jotai/react' +import type { Atom } from 'jotai/vanilla' + +type Store = ReturnType +type Options = Parameters[0] + +const hydratedMap: WeakMap>> = new WeakMap() + +export function useHydrateAtoms( + values: Iterable, unknown]>, + options?: Options +) { + const store = useStore(options) + + const hydratedSet = getHydratedSet(store) + const tuplesToRestore: (readonly [Atom, unknown])[] = [] + for (const tuple of values) { + const atom = tuple[0] + if (!hydratedSet.has(atom)) { + hydratedSet.add(atom) + tuplesToRestore.push(tuple) + } + } + if (tuplesToRestore.length) { + store.res(tuplesToRestore) + } +} + +const getHydratedSet = (store: Store) => { + let hydratedSet = hydratedMap.get(store) + if (!hydratedSet) { + hydratedSet = new WeakSet() + hydratedMap.set(store, hydratedSet) + } + return hydratedSet +} diff --git a/src/react/utils/useReducerAtom.ts b/src/react/utils/useReducerAtom.ts new file mode 100644 index 0000000000..1ced60a89a --- /dev/null +++ b/src/react/utils/useReducerAtom.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react' +import { useAtom } from 'jotai/react' +import type { PrimitiveAtom } from 'jotai/vanilla' + +type Options = Parameters[1] + +export function useReducerAtom( + anAtom: PrimitiveAtom, + reducer: (v: Value, a?: Action) => Value, + options?: Options +): [Value, (action?: Action) => void] + +export function useReducerAtom( + anAtom: PrimitiveAtom, + reducer: (v: Value, a: Action) => Value, + options?: Options +): [Value, (action: Action) => void] + +export function useReducerAtom( + anAtom: PrimitiveAtom, + reducer: (v: Value, a: Action) => Value, + options?: Options +) { + const [state, setState] = useAtom(anAtom, options) + const dispatch = useCallback( + (action: Action) => { + setState((prev) => reducer(prev, action)) + }, + [setState, reducer] + ) + return [state, dispatch] +} diff --git a/src/react/utils/useResetAtom.ts b/src/react/utils/useResetAtom.ts new file mode 100644 index 0000000000..26b472d477 --- /dev/null +++ b/src/react/utils/useResetAtom.ts @@ -0,0 +1,15 @@ +import { useCallback } from 'react' +import { useSetAtom } from 'jotai/react' +import type { WritableAtom } from 'jotai/vanilla' +import { RESET } from 'jotai/vanilla/utils' + +type Options = Parameters[1] + +export function useResetAtom( + anAtom: WritableAtom, + options?: Options +) { + const setAtom = useSetAtom(anAtom, options) + const resetAtom = useCallback(() => setAtom(RESET), [setAtom]) + return resetAtom +} diff --git a/src/utils/atomWithStorage.ts b/src/utils/atomWithStorage.ts index 86d90ef649..a071594e3f 100644 --- a/src/utils/atomWithStorage.ts +++ b/src/utils/atomWithStorage.ts @@ -1,8 +1,9 @@ import { atom } from 'jotai' import type { WritableAtom } from 'jotai' +import { unstable_NO_STORAGE_VALUE as NO_STORAGE_VALUE } from 'jotai/vanilla/utils' import { RESET } from './constants' -export const NO_STORAGE_VALUE = Symbol() +export { unstable_NO_STORAGE_VALUE as NO_STORAGE_VALUE } from 'jotai/vanilla/utils' type Unsubscribe = () => void diff --git a/src/utils/constants.ts b/src/utils/constants.ts index c182e83561..cef0526eb8 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1 +1 @@ -export const RESET = Symbol() +export { RESET } from 'jotai/vanilla/utils' diff --git a/src/vanilla.ts b/src/vanilla.ts new file mode 100644 index 0000000000..a50228b6c9 --- /dev/null +++ b/src/vanilla.ts @@ -0,0 +1,15 @@ +/** + * These APIs are still unstable. + * See: https://github.com/pmndrs/jotai/discussions/1514 + */ +export { atom } from './vanilla/atom' +export type { Atom, WritableAtom, PrimitiveAtom } from './vanilla/atom' +export { createStore, getDefaultStore } from './vanilla/store' +export type { + Getter, + Setter, + ExtractAtomValue, + ExtractAtomArgs, + ExtractAtomResult, + SetStateAction, +} from './vanilla/typeUtils' diff --git a/src/vanilla/atom.ts b/src/vanilla/atom.ts new file mode 100644 index 0000000000..553c8c02a8 --- /dev/null +++ b/src/vanilla/atom.ts @@ -0,0 +1,102 @@ +type Getter = (atom: Atom) => Value + +type Setter = ( + atom: WritableAtom, + ...args: Args +) => Result + +type Retry = () => void + +type Read = ( + get: Getter, + options: { readonly signal: AbortSignal; readonly retry: Retry } +) => Value + +type Write = ( + get: Getter, + set: Setter, + ...args: Args +) => Result + +type WithInitialValue = { + init: Value +} + +type SetAtom = (...args: Args) => Result + +type OnUnmount = () => void + +type OnMount = < + S extends SetAtom +>( + setAtom: S +) => OnUnmount | void + +export interface Atom { + toString: () => string + debugLabel?: string + read: Read +} + +export interface WritableAtom + extends Atom { + write: Write + onMount?: OnMount +} + +type SetStateAction = Value | ((prev: Value) => Value) + +export type PrimitiveAtom = WritableAtom< + Value, + [SetStateAction], + void +> + +let keyCount = 0 // global key count for all atoms + +// writable derived atom +export function atom( + read: Read, + write: Write +): WritableAtom + +// read-only derived atom +export function atom(read: Read): Atom + +// write-only derived atom +export function atom( + initialValue: Value, + write: Write +): WritableAtom & WithInitialValue + +// primitive atom +export function atom( + initialValue: Value +): PrimitiveAtom & WithInitialValue + +export function atom( + read: Value | Read, + write?: Write +) { + const key = `atom${++keyCount}` + const config = { + toString: () => key, + } as WritableAtom & { init?: Value } + if (typeof read === 'function') { + config.read = read as Read + } else { + config.init = read + config.read = (get) => get(config) + config.write = ((get: Getter, set: Setter, arg: SetStateAction) => + set( + config as unknown as PrimitiveAtom, + typeof arg === 'function' + ? (arg as (prev: Value) => Value)(get(config)) + : arg + )) as unknown as Write + } + if (write) { + config.write = write + } + return config +} diff --git a/src/vanilla/store.ts b/src/vanilla/store.ts new file mode 100644 index 0000000000..658d2656dd --- /dev/null +++ b/src/vanilla/store.ts @@ -0,0 +1,609 @@ +import type { Atom, WritableAtom } from './atom' + +type AnyValue = unknown +type AnyError = unknown +type AnyAtom = Atom +type AnyWritableAtom = WritableAtom +type OnUnmount = () => void +type Getter = Parameters[0] +type Setter = Parameters[1] + +const hasInitialValue = >( + atom: T +): atom is T & (T extends Atom ? { init: Value } : never) => + 'init' in atom + +type CancelPromise = (next?: Promise) => void +const cancelPromiseMap = new WeakMap, CancelPromise>() + +const registerCancelPromise = ( + promise: Promise, + cancel: CancelPromise +) => { + cancelPromiseMap.set(promise, cancel) + promise.catch(() => {}).finally(() => cancelPromiseMap.delete(promise)) +} + +const cancelPromise = (promise: Promise, next?: Promise) => { + const cancel = cancelPromiseMap.get(promise) + if (cancel) { + cancelPromiseMap.delete(promise) + cancel(next) + } +} + +/** + * Immutable map from a dependency to the dependency's atom state + * when it was last read. + * We can skip recomputation of an atom by comparing the atom state + * of each dependency to that dependencies's current revision. + */ +type Dependencies = Map + +/** + * Immutable atom state, + * tracked for both mounted and unmounted atoms in a store. + */ +type AtomState = { + d: Dependencies +} & ({ e: AnyError } | { v: Value }) + +const isEqualAtomValue = (a: AtomState, b: AtomState) => + 'v' in a && 'v' in b && Object.is(a.v, b.v) + +const isEqualAtomError = (a: AtomState, b: AtomState) => + 'e' in a && 'e' in b && Object.is(a.e, b.e) + +const hasPromiseAtomValue = ( + a: AtomState +): a is AtomState & { v: Value & Promise } => + 'v' in a && a.v instanceof Promise + +const returnAtomValue = (atomState: AtomState): Value => { + if ('e' in atomState) { + throw atomState.e + } + return atomState.v +} + +type Listeners = Set<() => void> +type Dependents = Set + +/** + * 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. + */ +type Mounted = { + /** The list of subscriber functions. */ + l: Listeners + /** Atoms that depend on *this* atom. Used to fan out invalidation. */ + t: Dependents + /** Function to run when the atom is unmounted. */ + u?: OnUnmount +} + +// for debugging purpose only +type StateListener = () => void +type MountedAtoms = Set + +/** + * Create a new store. Each store is an independent, isolated universe of atom + * states. + * + * Jotai atoms are not themselves state containers. When you read or write an + * atom, that state is stored in a store. You can think of a Store like a + * multi-layered map from atoms to states, like this: + * + * ``` + * // Conceptually, a Store is a map from atoms to states. + * // The real type is a bit different. + * type Store = Map> + * ``` + * + * @returns A store. + */ +export const createStore = () => { + const atomStateMap = new WeakMap() + const mountedMap = new WeakMap() + const pendingMap = new Map< + AnyAtom, + AtomState /* prevAtomState */ | undefined + >() + let stateListeners: Set + let mountedAtoms: MountedAtoms + if (__DEV__) { + stateListeners = new Set() + mountedAtoms = new Set() + } + + const getAtomState = (atom: Atom) => + atomStateMap.get(atom) as AtomState | undefined + + const setAtomState = ( + atom: Atom, + atomState: AtomState + ): void => { + if (__DEV__) { + Object.freeze(atomState) + } + const prevAtomState = atomStateMap.get(atom) + atomStateMap.set(atom, atomState) + if (!pendingMap.has(atom)) { + pendingMap.set(atom, prevAtomState) + } + if (prevAtomState && hasPromiseAtomValue(prevAtomState)) { + const next = + 'v' in atomState + ? atomState.v instanceof Promise + ? atomState.v + : Promise.resolve(atomState.v) + : Promise.reject(atomState.e) + cancelPromise(prevAtomState.v, next) + } + } + + const updateDependencies = ( + atom: Atom, + nextAtomState: AtomState, + depSet: Set + ): void => { + const dependencies: Dependencies = new Map() + let changed = false + depSet.forEach((a) => { + const aState = a === atom ? nextAtomState : getAtomState(a) + if (aState) { + dependencies.set(a, aState) + if (nextAtomState.d.get(a) !== aState) { + changed = true + } + } else if (__DEV__) { + console.warn('[Bug] atom state not found') + } + }) + if (changed || nextAtomState.d.size !== dependencies.size) { + nextAtomState.d = dependencies + } + } + + const setAtomValue = ( + atom: Atom, + value: Value, + depSet?: Set + ): AtomState => { + const prevAtomState = getAtomState(atom) + const nextAtomState: AtomState = { + d: prevAtomState?.d || new Map(), + v: value, + } + if (depSet) { + updateDependencies(atom, nextAtomState, depSet) + } + if ( + prevAtomState && + isEqualAtomValue(prevAtomState, nextAtomState) && + prevAtomState.d === nextAtomState.d + ) { + // bail out + return prevAtomState + } + setAtomState(atom, nextAtomState) + return nextAtomState + } + + const setAtomError = ( + atom: Atom, + error: AnyError, + depSet?: Set + ): AtomState => { + const prevAtomState = getAtomState(atom) + const nextAtomState: AtomState = { + d: prevAtomState?.d || new Map(), + e: error, + } + if (depSet) { + updateDependencies(atom, nextAtomState, depSet) + } + if ( + prevAtomState && + isEqualAtomError(prevAtomState, nextAtomState) && + prevAtomState.d === nextAtomState.d + ) { + // bail out + return prevAtomState + } + setAtomState(atom, nextAtomState) + return nextAtomState + } + + const readAtomState = ( + atom: Atom, + force?: boolean + ): AtomState => { + if (!force) { + // See if we can skip recomputing this atom. + const atomState = getAtomState(atom) + if (atomState) { + // Ensure that each atom we depend on is up to date. + // Recursive calls to `readAtomState(a)` will recompute `a` if + // it's out of date thus increment its revision number if it changes. + atomState.d.forEach((_, a) => { + if (a !== atom && !mountedMap.has(a)) { + // Dependency is new or unmounted. + // Recomputing doesn't touch unmounted atoms, so we need to recurse + // into this dependency in case it needs to update. + readAtomState(a) + } + }) + // If a dependency changed since this atom was last computed, + // then we're out of date and need to recompute. + if ( + Array.from(atomState.d).every( + ([a, s]) => a === atom || getAtomState(a) === s + ) + ) { + return atomState + } + } + } + // Compute a new state for this atom. + const depSet = new Set() + let isSync = true + const getter: Getter = (a: Atom) => { + depSet.add(a) + if ((a as AnyAtom) === atom) { + const aState = getAtomState(a) + if (aState) { + return returnAtomValue(aState) + } + if (hasInitialValue(a)) { + return a.init + } + // NOTE invalid derived atoms can reach here + throw new Error('no atom init') + } + // a !== atom + const aState = readAtomState(a) + return returnAtomValue(aState) + } + let controller: AbortController | undefined + let retry: (() => void) | undefined + const options = { + get signal() { + if (!controller) { + controller = new AbortController() + } + return controller.signal + }, + get retry() { + if (!retry) { + retry = () => { + if (!isSync) { + const prevAtomState = getAtomState(atom) + const nextAtomState = readAtomState(atom, true) + if ( + !prevAtomState || + !isEqualAtomValue(prevAtomState, nextAtomState) + ) { + recomputeDependents(atom) + } + flushPending() + } else if (__DEV__) { + console.warn('retry function cannot be called in sync') + } + } + } + return retry + }, + } + try { + const value = atom.read(getter, options) + if (value instanceof Promise) { + let continuePromise: (next: Promise>) => void + const promise: Promise> & { + status?: 'pending' | 'fulfilled' | 'rejected' + value?: Awaited + reason?: AnyError + } = new Promise((resolve, reject) => { + value + .then( + (v) => { + promise.status = 'fulfilled' + promise.value = v + resolve(v) + }, + (e) => { + promise.status = 'rejected' + promise.reason = e + reject(e) + } + ) + .finally(() => { + setAtomValue(atom, promise as Value, depSet) + }) + continuePromise = (next) => resolve(next) + }) + promise.status = 'pending' + registerCancelPromise(promise, (next) => { + if (next) { + continuePromise(next as Promise>) + } + controller?.abort() + }) + return setAtomValue(atom, promise as Value, depSet) + } + return setAtomValue(atom, value, depSet) + } catch (error) { + return setAtomError(atom, error, depSet) + } finally { + isSync = false + } + } + + const readAtom = (atom: Atom): Value => + returnAtomValue(readAtomState(atom)) + + const addAtom = (atom: AnyAtom): Mounted => { + let mounted = mountedMap.get(atom) + if (!mounted) { + mounted = mountAtom(atom) + } + return mounted + } + + // FIXME doesn't work with mutually dependent atoms + const canUnmountAtom = (atom: AnyAtom, mounted: Mounted) => + !mounted.l.size && + (!mounted.t.size || (mounted.t.size === 1 && mounted.t.has(atom))) + + const delAtom = (atom: AnyAtom): void => { + const mounted = mountedMap.get(atom) + if (mounted && canUnmountAtom(atom, mounted)) { + unmountAtom(atom) + } + } + + const recomputeDependents = (atom: Atom): void => { + const mounted = mountedMap.get(atom) + mounted?.t.forEach((dependent) => { + if (dependent !== atom) { + const prevAtomState = getAtomState(dependent) + const nextAtomState = readAtomState(dependent) + if (!prevAtomState || !isEqualAtomValue(prevAtomState, nextAtomState)) { + recomputeDependents(dependent) + } + } + }) + } + + const writeAtomState = ( + atom: WritableAtom, + ...args: Args + ): Result => { + let isSync = true + const getter: Getter = (a: Atom) => returnAtomValue(readAtomState(a)) + const setter: Setter = ( + a: WritableAtom, + ...args: As + ) => { + let r: R | undefined + if ((a as AnyWritableAtom) === atom) { + if (!hasInitialValue(a)) { + // NOTE technically possible but restricted as it may cause bugs + throw new Error('atom not writable') + } + const prevAtomState = getAtomState(a) + const nextAtomState = setAtomValue(a, args[0] as V) + if (!prevAtomState || !isEqualAtomValue(prevAtomState, nextAtomState)) { + recomputeDependents(a) + } + } else { + r = writeAtomState(a as AnyWritableAtom, ...args) as R + } + if (!isSync) { + flushPending() + } + return r as R + } + const result = atom.write(getter, setter, ...args) + isSync = false + return result + } + + const writeAtom = ( + atom: WritableAtom, + ...args: Args + ): Result => { + const result = writeAtomState(atom, ...args) + flushPending() + return result + } + + const isActuallyWritableAtom = (atom: AnyAtom): atom is AnyWritableAtom => + !!(atom as AnyWritableAtom).write + + const mountAtom = ( + atom: Atom, + initialDependent?: AnyAtom + ): Mounted => { + // mount self + const mounted: Mounted = { + t: new Set(initialDependent && [initialDependent]), + l: new Set(), + } + mountedMap.set(atom, mounted) + if (__DEV__) { + mountedAtoms.add(atom) + } + // mount dependencies before onMount + readAtomState(atom).d.forEach((_, a) => { + const aMounted = mountedMap.get(a) + if (aMounted) { + aMounted.t.add(atom) // add dependent + } else { + if (a !== atom) { + mountAtom(a, atom) + } + } + }) + // recompute atom state + readAtomState(atom) + // onMount + if (isActuallyWritableAtom(atom) && atom.onMount) { + const onUnmount = atom.onMount((...args) => writeAtom(atom, ...args)) + if (onUnmount) { + mounted.u = onUnmount + } + } + return mounted + } + + const unmountAtom = (atom: Atom): void => { + // unmount self + const onUnmount = mountedMap.get(atom)?.u + if (onUnmount) { + onUnmount() + } + mountedMap.delete(atom) + if (__DEV__) { + mountedAtoms.delete(atom) + } + // unmount dependencies afterward + const atomState = getAtomState(atom) + if (atomState) { + // cancel promise + if (hasPromiseAtomValue(atomState)) { + cancelPromise(atomState.v) + } + atomState.d.forEach((_, a) => { + if (a !== atom) { + const mounted = mountedMap.get(a) + if (mounted) { + mounted.t.delete(atom) + if (canUnmountAtom(a, mounted)) { + unmountAtom(a) + } + } + } + }) + } else if (__DEV__) { + console.warn('[Bug] could not find atom state to unmount', atom) + } + } + + const mountDependencies = ( + atom: Atom, + atomState: AtomState, + prevDependencies?: Dependencies + ): void => { + const depSet = new Set(atomState.d.keys()) + prevDependencies?.forEach((_, a) => { + if (depSet.has(a)) { + // not changed + depSet.delete(a) + return + } + const mounted = mountedMap.get(a) + if (mounted) { + mounted.t.delete(atom) // delete from dependents + if (canUnmountAtom(a, mounted)) { + unmountAtom(a) + } + } + }) + depSet.forEach((a) => { + const mounted = mountedMap.get(a) + if (mounted) { + mounted.t.add(atom) // add to dependents + } else if (mountedMap.has(atom)) { + // we mount dependencies only when atom is already mounted + // Note: we should revisit this when you find other issues + // https://github.com/pmndrs/jotai/issues/942 + mountAtom(a, atom) + } + }) + } + + const flushPending = (): void => { + while (pendingMap.size) { + const pending = Array.from(pendingMap) + pendingMap.clear() + pending.forEach(([atom, prevAtomState]) => { + const atomState = getAtomState(atom) + if (atomState) { + if (atomState.d !== prevAtomState?.d) { + mountDependencies(atom, atomState, prevAtomState?.d) + } + const mounted = mountedMap.get(atom) + mounted?.l.forEach((listener) => listener()) + } else if (__DEV__) { + console.warn('[Bug] no atom state to flush') + } + }) + } + if (__DEV__) { + stateListeners.forEach((l) => l()) + } + } + + const subscribeAtom = (atom: AnyAtom, listener: () => void) => { + const mounted = addAtom(atom) + const listeners = mounted.l + listeners.add(listener) + flushPending() + return () => { + listeners.delete(listener) + delAtom(atom) + } + } + + const restoreAtoms = ( + values: Iterable + ): void => { + for (const [atom, value] of values) { + if (hasInitialValue(atom)) { + setAtomValue(atom, value) + recomputeDependents(atom) + } + } + flushPending() + } + + if (__DEV__) { + return { + get: readAtom, + set: writeAtom, + sub: subscribeAtom, + res: restoreAtoms, + // store dev methods (these are tentative and subject to change) + dev_subscribe_state: (l: StateListener) => { + stateListeners.add(l) + return () => { + stateListeners.delete(l) + } + }, + dev_get_mounted_atoms: () => mountedAtoms.values(), + dev_get_atom_state: (a: AnyAtom) => atomStateMap.get(a), + dev_get_mounted: (a: AnyAtom) => mountedMap.get(a), + } + } + return { + get: readAtom, + set: writeAtom, + sub: subscribeAtom, + res: restoreAtoms, + } +} + +type Store = ReturnType + +let defaultStore: Store | undefined + +export const getDefaultStore = () => { + if (!defaultStore) { + defaultStore = createStore() + } + return defaultStore +} diff --git a/src/vanilla/typeUtils.ts b/src/vanilla/typeUtils.ts new file mode 100644 index 0000000000..8529f736de --- /dev/null +++ b/src/vanilla/typeUtils.ts @@ -0,0 +1,28 @@ +import type { Atom, PrimitiveAtom, WritableAtom } from './atom' + +export type Getter = Parameters['read']>[0] +export type Setter = Parameters< + WritableAtom['write'] +>[1] + +export type ExtractAtomValue = AtomType extends Atom + ? Value + : never + +export type ExtractAtomArgs = AtomType extends WritableAtom< + unknown, + infer Args, + unknown +> + ? Args + : never + +export type ExtractAtomResult = AtomType extends WritableAtom< + unknown, + unknown[], + infer Result +> + ? Result + : never + +export type SetStateAction = ExtractAtomArgs>[0] diff --git a/src/vanilla/utils.ts b/src/vanilla/utils.ts new file mode 100644 index 0000000000..3a15aa4709 --- /dev/null +++ b/src/vanilla/utils.ts @@ -0,0 +1,20 @@ +/** + * These APIs are still unstable. + * See: https://github.com/pmndrs/jotai/discussions/1514 + */ +export { RESET } from './utils/constants' +export { atomWithReset } from './utils/atomWithReset' +export { atomWithReducer } from './utils/atomWithReducer' +export { atomFamily } from './utils/atomFamily' +export { selectAtom } from './utils/selectAtom' +export { freezeAtom, freezeAtomCreator } from './utils/freezeAtom' +export { splitAtom } from './utils/splitAtom' +export { atomWithDefault } from './utils/atomWithDefault' +export { + NO_STORAGE_VALUE as unstable_NO_STORAGE_VALUE, + atomWithStorage, + createJSONStorage, +} from './utils/atomWithStorage' +export { atomWithObservable } from './utils/atomWithObservable' +export { loadable } from './utils/loadable' +export { unwrapAtom as unstable_unwrapAtom } from './utils/unwrapAtom' diff --git a/src/vanilla/utils/atomFamily.ts b/src/vanilla/utils/atomFamily.ts new file mode 100644 index 0000000000..f4d0dae84a --- /dev/null +++ b/src/vanilla/utils/atomFamily.ts @@ -0,0 +1,73 @@ +import type { Atom } from 'jotai/vanilla' + +type ShouldRemove = (createdAt: number, param: Param) => boolean + +export interface AtomFamily { + (param: Param): AtomType + remove(param: Param): void + setShouldRemove(shouldRemove: ShouldRemove | null): void +} + +export function atomFamily>( + initializeAtom: (param: Param) => AtomType, + areEqual?: (a: Param, b: Param) => boolean +): AtomFamily + +export function atomFamily>( + initializeAtom: (param: Param) => AtomType, + areEqual?: (a: Param, b: Param) => boolean +) { + type CreatedAt = number // in milliseconds + let shouldRemove: ShouldRemove | null = null + const atoms: Map = new Map() + const createAtom = (param: Param) => { + let item: [AtomType, CreatedAt] | undefined + if (areEqual === undefined) { + item = atoms.get(param) + } else { + // Custom comparator, iterate over all elements + for (const [key, value] of atoms) { + if (areEqual(key, param)) { + item = value + break + } + } + } + + if (item !== undefined) { + if (shouldRemove?.(item[1], param)) { + createAtom.remove(param) + } else { + return item[0] + } + } + + const newAtom = initializeAtom(param) + atoms.set(param, [newAtom, Date.now()]) + return newAtom + } + + createAtom.remove = (param: Param) => { + if (areEqual === undefined) { + atoms.delete(param) + } else { + for (const [key] of atoms) { + if (areEqual(key, param)) { + atoms.delete(key) + break + } + } + } + } + + createAtom.setShouldRemove = (fn: ShouldRemove | null) => { + shouldRemove = fn + if (!shouldRemove) return + for (const [key, value] of atoms) { + if (shouldRemove(value[1], key)) { + atoms.delete(key) + } + } + } + return createAtom +} diff --git a/src/vanilla/utils/atomWithDefault.ts b/src/vanilla/utils/atomWithDefault.ts new file mode 100644 index 0000000000..321b20e075 --- /dev/null +++ b/src/vanilla/utils/atomWithDefault.ts @@ -0,0 +1,53 @@ +import { atom } from 'jotai/vanilla' +import type { Atom, SetStateAction, WritableAtom } from 'jotai/vanilla' +import { RESET } from './constants' + +type Read = Atom['read'] + +const updateValue = ( + prevValue: Value, + update: SetStateAction +): Value => + typeof update === 'function' + ? (update as (prev: Value) => Value)(prevValue) + : update + +export function atomWithDefault( + getDefault: Read +): WritableAtom< + Value, + [SetStateAction> | typeof RESET], + void | Promise +> { + const EMPTY = Symbol() + const overwrittenAtom = atom(EMPTY) + const anAtom: WritableAtom< + Value, + [SetStateAction> | typeof RESET], + void | Promise + > = atom( + (get, options) => { + const overwritten = get(overwrittenAtom) + if (overwritten !== EMPTY) { + return overwritten + } + return getDefault(get, options) + }, + (get, set, update) => { + if (update === RESET) { + return set(overwrittenAtom, EMPTY) + } + const prevValue = get(anAtom) + if (prevValue instanceof Promise) { + return prevValue.then((v) => + set(overwrittenAtom, updateValue(v, update)) + ) + } + return set( + overwrittenAtom, + updateValue(prevValue as Awaited, update) + ) + } + ) + return anAtom +} diff --git a/src/vanilla/utils/atomWithObservable.ts b/src/vanilla/utils/atomWithObservable.ts new file mode 100644 index 0000000000..9057c750d5 --- /dev/null +++ b/src/vanilla/utils/atomWithObservable.ts @@ -0,0 +1,181 @@ +import { atom } from 'jotai/vanilla' +import type { Atom, Getter, WritableAtom } from 'jotai/vanilla' + +type Timeout = ReturnType +type AnyError = unknown + +declare global { + interface SymbolConstructor { + readonly observable: symbol + } +} + +type Subscription = { + unsubscribe: () => void +} + +type Observer = { + next: (value: T) => void + error: (error: AnyError) => void + complete: () => void +} + +type ObservableLike = { + [Symbol.observable]?: () => ObservableLike | undefined +} & ( + | { + subscribe(observer: Partial>): Subscription + } + | { + subscribe(observer: Partial>): Subscription + // Overload function to make typing happy + subscribe(next: (value: T) => void): Subscription + } +) + +type SubjectLike = ObservableLike & Observer + +type Options = { + initialValue?: Data | (() => Data) + unstable_timeout?: number +} + +type OptionsWithInitialValue = { + initialValue: Data | (() => Data) + unstable_timeout?: number +} + +export function atomWithObservable( + getObservable: (get: Getter) => SubjectLike, + options: OptionsWithInitialValue +): WritableAtom + +export function atomWithObservable( + getObservable: (get: Getter) => SubjectLike, + options?: Options +): WritableAtom, [Data], void> + +export function atomWithObservable( + getObservable: (get: Getter) => ObservableLike, + options: OptionsWithInitialValue +): Atom + +export function atomWithObservable( + getObservable: (get: Getter) => ObservableLike, + options?: Options +): Atom> + +export function atomWithObservable( + getObservable: (get: Getter) => ObservableLike | SubjectLike, + options?: Options +) { + type Result = { d: Data } | { e: AnyError } + const returnResultData = (result: Result) => { + if ('e' in result) { + throw result.e + } + return result.d + } + + const observableResultAtom = atom((get) => { + let observable = getObservable(get) + const itself = observable[Symbol.observable]?.() + if (itself) { + observable = itself + } + + let resolve: ((result: Result) => void) | undefined + const makePending = () => + new Promise((r) => { + resolve = r + }) + const initialResult: Result | Promise = + options && 'initialValue' in options + ? { + d: + typeof options.initialValue === 'function' + ? (options.initialValue as () => Data)() + : (options.initialValue as Data), + } + : makePending() + + let setResult: ((result: Result) => void) | undefined + let lastResult: Result | undefined + const listener = (result: Result) => { + lastResult = result + resolve?.(result) + setResult?.(result) + } + + let subscription: Subscription | undefined + let timer: Timeout | undefined + const isNotMounted = () => !setResult + const start = () => { + if (subscription) { + clearTimeout(timer) + subscription.unsubscribe() + } + subscription = observable.subscribe({ + next: (d) => listener({ d }), + error: (e) => listener({ e }), + complete: () => {}, + }) + if (isNotMounted() && options?.unstable_timeout) { + timer = setTimeout(() => { + if (subscription) { + subscription.unsubscribe() + subscription = undefined + } + }, options.unstable_timeout) + } + } + start() + + const resultAtom = atom(lastResult || initialResult) + resultAtom.onMount = (update) => { + setResult = update + if (lastResult) { + update(lastResult) + } + if (subscription) { + clearTimeout(timer) + } else { + start() + } + return () => { + setResult = undefined + if (subscription) { + subscription.unsubscribe() + subscription = undefined + } + } + } + return [resultAtom, observable, makePending, start, isNotMounted] as const + }) + + const observableAtom = atom( + (get) => { + const [resultAtom] = get(observableResultAtom) + const result = get(resultAtom) + if (result instanceof Promise) { + return result.then(returnResultData) + } + return returnResultData(result) + }, + (get, set, data: Data) => { + const [resultAtom, observable, makePending, start, isNotMounted] = + get(observableResultAtom) + if ('next' in observable) { + if (isNotMounted()) { + set(resultAtom, makePending()) + start() + } + observable.next(data) + } else { + throw new Error('observable is not subject') + } + } + ) + + return observableAtom +} diff --git a/src/vanilla/utils/atomWithReducer.ts b/src/vanilla/utils/atomWithReducer.ts new file mode 100644 index 0000000000..db6fca5393 --- /dev/null +++ b/src/vanilla/utils/atomWithReducer.ts @@ -0,0 +1,22 @@ +import { atom } from 'jotai/vanilla' +import type { WritableAtom } from 'jotai/vanilla' + +export function atomWithReducer( + initialValue: Value, + reducer: (value: Value, action?: Action) => Value +): WritableAtom + +export function atomWithReducer( + initialValue: Value, + reducer: (value: Value, action: Action) => Value +): WritableAtom + +export function atomWithReducer( + initialValue: Value, + reducer: (value: Value, action: Action) => Value +) { + const anAtom: any = atom(initialValue, (get, set, action: Action) => + set(anAtom, reducer(get(anAtom), action)) + ) + return anAtom +} diff --git a/src/vanilla/utils/atomWithReset.ts b/src/vanilla/utils/atomWithReset.ts new file mode 100644 index 0000000000..d7c1b0d881 --- /dev/null +++ b/src/vanilla/utils/atomWithReset.ts @@ -0,0 +1,24 @@ +import { atom } from 'jotai/vanilla' +import type { WritableAtom } from 'jotai/vanilla' +import { RESET } from './constants' + +type SetStateActionWithReset = + | Value + | typeof RESET + | ((prev: Value) => Value | typeof RESET) + +export function atomWithReset(initialValue: Value) { + type Update = SetStateActionWithReset + const anAtom = atom( + initialValue, + (get, set, update) => { + const nextValue = + typeof update === 'function' + ? (update as (prev: Value) => Value | typeof RESET)(get(anAtom)) + : update + + set(anAtom, nextValue === RESET ? initialValue : nextValue) + } + ) + return anAtom as WritableAtom +} diff --git a/src/vanilla/utils/atomWithStorage.ts b/src/vanilla/utils/atomWithStorage.ts new file mode 100644 index 0000000000..8e0efdbd41 --- /dev/null +++ b/src/vanilla/utils/atomWithStorage.ts @@ -0,0 +1,142 @@ +import { atom } from 'jotai/vanilla' +import type { WritableAtom } from 'jotai/vanilla' +import { RESET } from './constants' + +export const NO_STORAGE_VALUE = Symbol() + +type Unsubscribe = () => void + +type SetStateActionWithReset = + | Value + | typeof RESET + | ((prev: Value) => Value | typeof RESET) + +export interface AsyncStorage { + getItem: (key: string) => Promise + setItem: (key: string, newValue: Value) => Promise + removeItem: (key: string) => Promise + subscribe?: (key: string, callback: (value: Value) => void) => Unsubscribe +} + +export interface SyncStorage { + getItem: (key: string) => Value | typeof NO_STORAGE_VALUE + setItem: (key: string, newValue: Value) => void + removeItem: (key: string) => void + subscribe?: (key: string, callback: (value: Value) => void) => Unsubscribe +} + +export interface AsyncStringStorage { + getItem: (key: string) => Promise + setItem: (key: string, newValue: string) => Promise + removeItem: (key: string) => Promise +} + +export interface SyncStringStorage { + getItem: (key: string) => string | null + setItem: (key: string, newValue: string) => void + removeItem: (key: string) => void +} + +export function createJSONStorage( + getStringStorage: () => AsyncStringStorage +): AsyncStorage + +export function createJSONStorage( + getStringStorage: () => SyncStringStorage +): SyncStorage + +export function createJSONStorage( + getStringStorage: () => AsyncStringStorage | SyncStringStorage | undefined +): AsyncStorage | SyncStorage { + let lastStr: string | undefined + let lastValue: any + const storage: AsyncStorage | SyncStorage = { + getItem: (key) => { + const parse = (str: string | null) => { + str = str || '' + if (lastStr !== str) { + try { + lastValue = JSON.parse(str) + } catch { + return NO_STORAGE_VALUE + } + lastStr = str + } + return lastValue + } + const str = getStringStorage()?.getItem(key) ?? null + if (str instanceof Promise) { + return str.then(parse) + } + return parse(str) + }, + setItem: (key, newValue) => + getStringStorage()?.setItem(key, JSON.stringify(newValue)), + removeItem: (key) => getStringStorage()?.removeItem(key), + } + if ( + typeof window !== 'undefined' && + typeof window.addEventListener === 'function' + ) { + storage.subscribe = (key, callback) => { + const storageEventCallback = (e: StorageEvent) => { + if (e.key === key && e.newValue) { + callback(JSON.parse(e.newValue)) + } + } + window.addEventListener('storage', storageEventCallback) + return () => { + window.removeEventListener('storage', storageEventCallback) + } + } + } + return storage +} + +const defaultStorage = createJSONStorage(() => + typeof window !== 'undefined' + ? window.localStorage + : (undefined as unknown as Storage) +) + +export function atomWithStorage( + key: string, + initialValue: Value, + storage: + | SyncStorage + | AsyncStorage = defaultStorage as SyncStorage +): WritableAtom], void> { + const baseAtom = atom(initialValue) + + baseAtom.onMount = (setAtom) => { + const value = storage.getItem(key) + if (value instanceof Promise) { + value.then((v) => setAtom(v === NO_STORAGE_VALUE ? initialValue : v)) + } else { + setAtom(value === NO_STORAGE_VALUE ? initialValue : value) + } + let unsub: Unsubscribe | undefined + if (storage.subscribe) { + unsub = storage.subscribe(key, setAtom) + } + return unsub + } + + const anAtom = atom( + (get) => get(baseAtom), + (get, set, update: SetStateActionWithReset) => { + const nextValue = + typeof update === 'function' + ? (update as (prev: Value) => Value | typeof RESET)(get(baseAtom)) + : update + if (nextValue === RESET) { + set(baseAtom, initialValue) + return storage.removeItem(key) + } + set(baseAtom, nextValue) + return storage.setItem(key, nextValue) + } + ) + + return anAtom +} diff --git a/src/vanilla/utils/constants.ts b/src/vanilla/utils/constants.ts new file mode 100644 index 0000000000..c182e83561 --- /dev/null +++ b/src/vanilla/utils/constants.ts @@ -0,0 +1 @@ +export const RESET = Symbol() diff --git a/src/vanilla/utils/freezeAtom.ts b/src/vanilla/utils/freezeAtom.ts new file mode 100644 index 0000000000..e05d80039d --- /dev/null +++ b/src/vanilla/utils/freezeAtom.ts @@ -0,0 +1,40 @@ +import { atom } from 'jotai/vanilla' +import type { Atom } from 'jotai/vanilla' + +const cache1 = new WeakMap() +const memo1 = (create: () => T, dep1: object): T => + (cache1.has(dep1) ? cache1 : cache1.set(dep1, create())).get(dep1) + +const deepFreeze = (obj: any) => { + if (typeof obj !== 'object' || obj === null) return + Object.freeze(obj) + const propNames = Object.getOwnPropertyNames(obj) + for (const name of propNames) { + const value = obj[name] + deepFreeze(value) + } + return obj +} + +export function freezeAtom>( + anAtom: AtomType +): AtomType { + return memo1(() => { + const frozenAtom: any = atom( + (get) => deepFreeze(get(anAtom)), + (_get, set, arg) => set(anAtom as any, arg) + ) + return frozenAtom + }, anAtom) +} + +export function freezeAtomCreator< + CreateAtom extends (...params: any[]) => Atom +>(createAtom: CreateAtom) { + return ((...params: any[]) => { + const anAtom = createAtom(...params) + const origRead = anAtom.read + anAtom.read = (get, options) => deepFreeze(origRead(get, options)) + return anAtom + }) as CreateAtom +} diff --git a/src/vanilla/utils/loadable.ts b/src/vanilla/utils/loadable.ts new file mode 100644 index 0000000000..637a2e532b --- /dev/null +++ b/src/vanilla/utils/loadable.ts @@ -0,0 +1,42 @@ +import { atom } from 'jotai/vanilla' +import type { Atom } from 'jotai/vanilla' + +const cache1 = new WeakMap() +const memo1 = (create: () => T, dep1: object): T => + (cache1.has(dep1) ? cache1 : cache1.set(dep1, create())).get(dep1) + +type Loadable = + | { state: 'loading' } + | { state: 'hasError'; error: unknown } + | { state: 'hasData'; data: Awaited } + +const LOADING: Loadable = { state: 'loading' } + +export function loadable(anAtom: Atom): Atom> { + return memo1(() => { + const loadableCache = new WeakMap, Loadable>() + const derivedAtom = atom((get, { retry }) => { + const promise = get(anAtom) + if (!(promise instanceof Promise)) { + return { state: 'hasData', data: promise } as Loadable + } + const cached = loadableCache.get(promise) + if (cached) { + return cached + } + loadableCache.set(promise, LOADING as Loadable) + promise + .then( + (data) => { + loadableCache.set(promise, { state: 'hasData', data }) + }, + (error) => { + loadableCache.set(promise, { state: 'hasError', error }) + } + ) + .finally(retry) + return LOADING as Loadable + }) + return derivedAtom + }, anAtom) +} diff --git a/src/vanilla/utils/selectAtom.ts b/src/vanilla/utils/selectAtom.ts new file mode 100644 index 0000000000..49ae7df159 --- /dev/null +++ b/src/vanilla/utils/selectAtom.ts @@ -0,0 +1,60 @@ +import { atom } from 'jotai/vanilla' +import type { Atom } from 'jotai/vanilla' + +const getCached = (c: () => T, m: WeakMap, k: object): T => + (m.has(k) ? m : m.set(k, c())).get(k) as T +const cache1 = new WeakMap() +const memo3 = ( + create: () => T, + dep1: object, + dep2: object, + dep3: object +): T => { + const cache2 = getCached(() => new WeakMap(), cache1, dep1) + const cache3 = getCached(() => new WeakMap(), cache2, dep2) + return getCached(create, cache3, dep3) +} + +export function selectAtom( + anAtom: Atom>, + selector: (v: Awaited) => Slice, + equalityFn?: (a: Slice, b: Slice) => boolean +): Atom> + +export function selectAtom( + anAtom: Atom, + selector: (v: Awaited) => Slice, + equalityFn?: (a: Slice, b: Slice) => boolean +): Atom + +export function selectAtom( + anAtom: Atom, + selector: (v: Awaited) => Slice, + equalityFn: (a: Slice, b: Slice) => boolean = Object.is +) { + return memo3( + () => { + // TODO we should revisit this for a better solution than refAtom + const refAtom = atom(() => ({} as { prev?: Slice })) + const derivedAtom = atom((get) => { + const ref = get(refAtom) + const selectValue = (value: Awaited) => { + const slice = selector(value) + if ('prev' in ref && equalityFn(ref.prev as Slice, slice)) { + return ref.prev as Slice + } + return (ref.prev = slice) + } + const value = get(anAtom) + if (value instanceof Promise) { + return value.then(selectValue) + } + return selectValue(value as Awaited) + }) + return derivedAtom + }, + anAtom, + selector, + equalityFn + ) +} diff --git a/src/vanilla/utils/splitAtom.ts b/src/vanilla/utils/splitAtom.ts new file mode 100644 index 0000000000..abb6bdd756 --- /dev/null +++ b/src/vanilla/utils/splitAtom.ts @@ -0,0 +1,207 @@ +import { atom } from 'jotai/vanilla' +import type { + Atom, + Getter, + PrimitiveAtom, + SetStateAction, + Setter, + WritableAtom, +} from 'jotai/vanilla' + +const getCached = (c: () => T, m: WeakMap, k: object): T => + (m.has(k) ? m : m.set(k, c())).get(k) as T +const cache1 = new WeakMap() +const memo2 = (create: () => T, dep1: object, dep2: object): T => { + const cache2 = getCached(() => new WeakMap(), cache1, dep1) + return getCached(create, cache2, dep2) +} +const cacheKeyForEmptyKeyExtractor = {} + +const isWritable = ( + atom: Atom | WritableAtom +): atom is WritableAtom => + !!(atom as WritableAtom).write + +const isFunction = (x: T): x is T & ((...args: any[]) => any) => + typeof x === 'function' + +type SplitAtomAction = + | { type: 'remove'; atom: PrimitiveAtom } + | { + type: 'insert' + value: Item + before?: PrimitiveAtom + } + | { + type: 'move' + atom: PrimitiveAtom + before?: PrimitiveAtom + } + +export function splitAtom( + arrAtom: WritableAtom, + keyExtractor?: (item: Item) => Key +): WritableAtom[], [SplitAtomAction], void> + +export function splitAtom( + arrAtom: Atom, + keyExtractor?: (item: Item) => Key +): Atom[]> + +export function splitAtom( + arrAtom: WritableAtom | Atom, + keyExtractor?: (item: Item) => Key +) { + return memo2( + () => { + type ItemAtom = PrimitiveAtom | Atom + type Mapping = { + atomList: ItemAtom[] + keyList: Key[] + } + const mappingCache = new WeakMap() + const getMapping = (arr: Item[], prev?: Item[]) => { + let mapping = mappingCache.get(arr) + if (mapping) { + return mapping + } + const prevMapping = prev && mappingCache.get(prev) + const atomList: Atom[] = [] + const keyList: Key[] = [] + arr.forEach((item, index) => { + const key = keyExtractor + ? keyExtractor(item) + : (index as unknown as Key) + keyList[index] = key + const cachedAtom = + prevMapping && + prevMapping.atomList[prevMapping.keyList.indexOf(key)] + if (cachedAtom) { + atomList[index] = cachedAtom + return + } + const read = (get: Getter) => { + const ref = get(refAtom) + const currArr = get(arrAtom) + const mapping = getMapping(currArr, ref.prev) + const index = mapping.keyList.indexOf(key) + if (index < 0 || index >= currArr.length) { + // returning a stale value to avoid errors for use cases such as react-spring + const prevItem = arr[getMapping(arr).keyList.indexOf(key)] + if (prevItem) { + return prevItem + } + throw new Error('splitAtom: index out of bounds for read') + } + return currArr[index] as Item + } + const write = ( + get: Getter, + set: Setter, + update: SetStateAction + ) => { + const ref = get(refAtom) + const arr = get(arrAtom) + const mapping = getMapping(arr, ref.prev) + const index = mapping.keyList.indexOf(key) + if (index < 0 || index >= arr.length) { + throw new Error('splitAtom: index out of bounds for write') + } + const nextItem = isFunction(update) + ? update(arr[index] as Item) + : update + set(arrAtom as WritableAtom, [ + ...arr.slice(0, index), + nextItem, + ...arr.slice(index + 1), + ]) + } + atomList[index] = isWritable(arrAtom) ? atom(read, write) : atom(read) + }) + if ( + prevMapping && + prevMapping.keyList.length === keyList.length && + prevMapping.keyList.every((x, i) => x === keyList[i]) + ) { + // not changed + mapping = prevMapping + } else { + mapping = { atomList, keyList } + } + mappingCache.set(arr, mapping) + return mapping + } + // TODO we should revisit this for a better solution than refAtom + const refAtom = atom(() => ({} as { prev?: Item[] })) + const read = (get: Getter) => { + const ref = get(refAtom) + const arr = get(arrAtom) + const mapping = getMapping(arr, ref.prev) + ref.prev = arr + return mapping.atomList + } + const write = ( + get: Getter, + set: Setter, + action: SplitAtomAction + ) => { + switch (action.type) { + case 'remove': { + const index = get(splittedAtom).indexOf(action.atom) + if (index >= 0) { + const arr = get(arrAtom) + set(arrAtom as WritableAtom, [ + ...arr.slice(0, index), + ...arr.slice(index + 1), + ]) + } + break + } + case 'insert': { + const index = action.before + ? get(splittedAtom).indexOf(action.before) + : get(splittedAtom).length + if (index >= 0) { + const arr = get(arrAtom) + set(arrAtom as WritableAtom, [ + ...arr.slice(0, index), + action.value, + ...arr.slice(index), + ]) + } + break + } + case 'move': { + const index1 = get(splittedAtom).indexOf(action.atom) + const index2 = action.before + ? get(splittedAtom).indexOf(action.before) + : get(splittedAtom).length + if (index1 >= 0 && index2 >= 0) { + const arr = get(arrAtom) + if (index1 < index2) { + set(arrAtom as WritableAtom, [ + ...arr.slice(0, index1), + ...arr.slice(index1 + 1, index2), + arr[index1] as Item, + ...arr.slice(index2), + ]) + } else { + set(arrAtom as WritableAtom, [ + ...arr.slice(0, index2), + arr[index1] as Item, + ...arr.slice(index2, index1), + ...arr.slice(index1 + 1), + ]) + } + } + break + } + } + } + const splittedAtom = isWritable(arrAtom) ? atom(read, write) : atom(read) + return splittedAtom + }, + arrAtom, + keyExtractor || cacheKeyForEmptyKeyExtractor + ) +} diff --git a/src/vanilla/utils/unwrapAtom.ts b/src/vanilla/utils/unwrapAtom.ts new file mode 100644 index 0000000000..94fa261ffc --- /dev/null +++ b/src/vanilla/utils/unwrapAtom.ts @@ -0,0 +1,39 @@ +import { atom } from 'jotai/vanilla' +import type { Atom } from 'jotai/vanilla' + +const cache1 = new WeakMap() +const memo1 = (create: () => T, dep1: object): T => + (cache1.has(dep1) ? cache1 : cache1.set(dep1, create())).get(dep1) + +export function unwrapAtom( + anAtom: Atom>, + defaultValue: Awaited +): Atom> { + return memo1(() => { + // TODO we should revisit this for a better solution than refAtom + const refAtom = atom( + () => ({} as { p?: Promise; v?: Awaited; e?: unknown }) + ) + const derivedAtom = atom((get, { retry }) => { + const ref = get(refAtom) + const promise = get(anAtom) + if (ref.p !== promise) { + promise + .then( + (v) => (ref.v = v as Awaited), + (e) => (ref.e = e) + ) + .finally(retry) + ref.p = promise + } + if ('e' in ref) { + throw ref.e + } + if ('v' in ref) { + return ref.v + } + return defaultValue + }) + return derivedAtom + }, anAtom) +} diff --git a/tests/react/abortable.test.tsx b/tests/react/abortable.test.tsx new file mode 100644 index 0000000000..115863c087 --- /dev/null +++ b/tests/react/abortable.test.tsx @@ -0,0 +1,217 @@ +import { StrictMode, Suspense, useState } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useAtomValue, useSetAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' + +describe('abortable atom test', () => { + it('can abort with signal.aborted', async () => { + const countAtom = atom(0) + let abortedCount = 0 + const resolve: (() => void)[] = [] + const derivedAtom = atom(async (get, { signal }) => { + const count = get(countAtom) + await new Promise((r) => resolve.push(r)) + if (signal.aborted) { + ++abortedCount + } + return count + }) + + const Component = () => { + const count = useAtomValue(derivedAtom) + return
count: {count}
+ } + + const Controls = () => { + const setCount = useSetAtom(countAtom) + return ( + <> + + + ) + } + + const { findByText, getByText } = render( + + + + + + + ) + + await findByText('loading') + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 0') + expect(abortedCount).toBe(0) + + fireEvent.click(getByText('button')) + fireEvent.click(getByText('button')) + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 2') + expect(abortedCount).toBe(1) + + fireEvent.click(getByText('button')) + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 3') + expect(abortedCount).toBe(1) + }) + + it('can abort with event listener', async () => { + const countAtom = atom(0) + let abortedCount = 0 + const resolve: (() => void)[] = [] + const derivedAtom = atom(async (get, { signal }) => { + const count = get(countAtom) + const callback = () => { + ++abortedCount + } + signal.addEventListener('abort', callback) + await new Promise((r) => resolve.push(r)) + signal.removeEventListener('abort', callback) + return count + }) + + const Component = () => { + const count = useAtomValue(derivedAtom) + return
count: {count}
+ } + + const Controls = () => { + const setCount = useSetAtom(countAtom) + return ( + <> + + + ) + } + + const { findByText, getByText } = render( + + + + + + + ) + + await findByText('loading') + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 0') + expect(abortedCount).toBe(0) + + fireEvent.click(getByText('button')) + fireEvent.click(getByText('button')) + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 2') + expect(abortedCount).toBe(1) + + fireEvent.click(getByText('button')) + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 3') + expect(abortedCount).toBe(1) + }) + + it('can abort on unmount', async () => { + const countAtom = atom(0) + let abortedCount = 0 + const resolve: (() => void)[] = [] + const derivedAtom = atom(async (get, { signal }) => { + const count = get(countAtom) + await new Promise((r) => resolve.push(r)) + if (signal.aborted) { + ++abortedCount + } + return count + }) + + const Component = () => { + const count = useAtomValue(derivedAtom) + return
count: {count}
+ } + + const Parent = () => { + const setCount = useSetAtom(countAtom) + const [show, setShow] = useState(true) + return ( + <> + {show ? : 'hidden'} + + + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('loading') + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 0') + expect(abortedCount).toBe(0) + + fireEvent.click(getByText('button')) + fireEvent.click(getByText('toggle')) + await findByText('hidden') + resolve.splice(0).forEach((fn) => fn()) + await waitFor(() => { + expect(abortedCount).toBe(1) + }) + }) + + it('throws aborted error (like fetch)', async () => { + const countAtom = atom(0) + const resolve: (() => void)[] = [] + const derivedAtom = atom(async (get, { signal }) => { + const count = get(countAtom) + await new Promise((r) => resolve.push(r)) + if (signal.aborted) { + throw new Error('aborted') + } + return count + }) + + const Component = () => { + const count = useAtomValue(derivedAtom) + return
count: {count}
+ } + + const Controls = () => { + const setCount = useSetAtom(countAtom) + return ( + <> + + + ) + } + + const { findByText, getByText } = render( + + + + + + + ) + + await findByText('loading') + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 0') + + fireEvent.click(getByText('button')) + fireEvent.click(getByText('button')) + await waitFor(() => { + resolve.splice(0).forEach((fn) => fn()) + getByText('count: 2') + }) + + fireEvent.click(getByText('button')) + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 3') + }) +}) diff --git a/tests/react/async.test.tsx b/tests/react/async.test.tsx new file mode 100644 index 0000000000..234648a45c --- /dev/null +++ b/tests/react/async.test.tsx @@ -0,0 +1,1131 @@ +import { StrictMode, Suspense, useEffect, useRef } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' +import type { Atom } from 'jotai/vanilla' + +const useCommitCount = () => { + const commitCountRef = useRef(1) + useEffect(() => { + commitCountRef.current += 1 + }) + return commitCountRef.current +} + +it('does not show async stale result', async () => { + const countAtom = atom(0) + let resolve2 = () => {} + const asyncCountAtom = atom(async (get) => { + await new Promise((r) => (resolve2 = r)) + return get(countAtom) + }) + + const committed: number[] = [] + + let resolve1 = () => {} + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const onClick = async () => { + setCount((c) => c + 1) + await new Promise((r) => (resolve1 = r)) + setCount((c) => c + 1) + } + return ( + <> +
count: {count}
+ + + ) + } + + const DelayedCounter = () => { + const [delayedCount] = useAtom(asyncCountAtom) + useEffect(() => { + committed.push(delayedCount) + }) + return
delayedCount: {delayedCount}
+ } + + const { getByText, findByText } = render( + <> + + + + + + ) + + await findByText('loading') + resolve1() + resolve2() + await waitFor(() => { + getByText('count: 0') + getByText('delayedCount: 0') + expect(committed).toEqual([0]) + }) + + fireEvent.click(getByText('button')) + await findByText('loading') + resolve1() + resolve2() + await waitFor(() => { + getByText('count: 2') + getByText('delayedCount: 2') + expect(committed).toEqual([0, 2]) + }) +}) + +it('does not show async stale result on derived atom', async () => { + const countAtom = atom(0) + let resolve = () => {} + const asyncAlwaysNullAtom = atom(async (get) => { + get(countAtom) + await new Promise((r) => (resolve = r)) + return null + }) + const derivedAtom = atom((get) => get(asyncAlwaysNullAtom)) + + const DisplayAsyncValue = () => { + const [asyncValue] = useAtom(asyncAlwaysNullAtom) + + return
async value: {JSON.stringify(asyncValue)}
+ } + + const DisplayDerivedValue = () => { + const [derivedValue] = useAtom(derivedAtom) + return
derived value: {JSON.stringify(derivedValue)}
+ } + + const Test = () => { + const [count, setCount] = useAtom(countAtom) + return ( +
+
count: {count}
+ loading async value
}> + + + loading derived value}> + + + + + ) + } + + const { getByText, queryByText } = render( + + + + ) + + await waitFor(() => { + getByText('count: 0') + getByText('loading async value') + getByText('loading derived value') + }) + resolve() + await waitFor(() => { + expect(queryByText('loading async value')).toBeNull() + expect(queryByText('loading derived value')).toBeNull() + }) + await waitFor(() => { + getByText('async value: null') + getByText('derived value: null') + }) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 1') + getByText('loading async value') + getByText('loading derived value') + }) + resolve() + await waitFor(() => { + expect(queryByText('loading async value')).toBeNull() + expect(queryByText('loading derived value')).toBeNull() + }) + await waitFor(() => { + getByText('async value: null') + getByText('derived value: null') + }) +}) + +it('works with async get with extra deps', async () => { + const countAtom = atom(0) + const anotherAtom = atom(-1) + let resolve = () => {} + const asyncCountAtom = atom(async (get) => { + get(anotherAtom) + await new Promise((r) => (resolve = r)) + return get(countAtom) + }) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const DelayedCounter = () => { + const [delayedCount] = useAtom(asyncCountAtom) + return
delayedCount: {delayedCount}
+ } + + const { getByText, findByText } = render( + + + + + + + ) + + await findByText('loading') + resolve() + await waitFor(() => { + getByText('count: 0') + getByText('delayedCount: 0') + }) + + fireEvent.click(getByText('button')) + await findByText('loading') + resolve() + await waitFor(() => { + getByText('count: 1') + getByText('delayedCount: 1') + }) +}) + +it('reuses promises on initial read', async () => { + let invokeCount = 0 + let resolve = () => {} + const asyncAtom = atom(async () => { + invokeCount += 1 + await new Promise((r) => (resolve = r)) + return 'ready' + }) + + const Child = () => { + const [str] = useAtom(asyncAtom) + return
{str}
+ } + + const { findByText, findAllByText } = render( + + + + + + + ) + + await findByText('loading') + resolve() + await findAllByText('ready') + expect(invokeCount).toBe(1) +}) + +it('uses multiple async atoms at once', async () => { + const resolve: (() => void)[] = [] + const someAtom = atom(async () => { + await new Promise((r) => resolve.push(r)) + return 'ready' + }) + const someAtom2 = atom(async () => { + await new Promise((r) => resolve.push(r)) + return 'ready2' + }) + + const Component = () => { + const [some] = useAtom(someAtom) + const [some2] = useAtom(someAtom2) + return ( + <> +
+ {some} {some2} +
+ + ) + } + + const { getByText, findByText } = render( + + + + + + ) + + await findByText('loading') + await waitFor(() => { + resolve.splice(0).forEach((fn) => fn()) + getByText('ready ready2') + }) +}) + +it('uses async atom in the middle of dependency chain', async () => { + const countAtom = atom(0) + let resolve = () => {} + const asyncCountAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)) + return get(countAtom) + }) + const delayedCountAtom = atom((get) => get(asyncCountAtom)) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const [delayedCount] = useAtom(delayedCountAtom) + return ( + <> +
+ count: {count}, delayed: {delayedCount} +
+ + + ) + } + + const { getByText, findByText } = render( + + + + + + ) + + await findByText('loading') + resolve() + await findByText('count: 0, delayed: 0') + + fireEvent.click(getByText('button')) + await findByText('loading') + resolve() + await findByText('count: 1, delayed: 1') +}) + +it('updates an async atom in child useEffect on remount without setTimeout', async () => { + const toggleAtom = atom(true) + const countAtom = atom(0) + const asyncCountAtom = atom( + async (get) => get(countAtom), + async (get, set) => set(countAtom, get(countAtom) + 1) + ) + + const Counter = () => { + const [count, incCount] = useAtom(asyncCountAtom) + useEffect(() => { + incCount() + }, [incCount]) + return
count: {count}
+ } + + const Parent = () => { + const [toggle, setToggle] = useAtom(toggleAtom) + return ( + <> + + {toggle ? :
no child
} + + ) + } + + const { getByText, findByText } = render( + <> + + + + + ) + + await findByText('count: 1') + + fireEvent.click(getByText('button')) + await findByText('no child') + + fireEvent.click(getByText('button')) + await findByText('count: 2') +}) + +it('updates an async atom in child useEffect on remount', async () => { + const toggleAtom = atom(true) + const countAtom = atom(0) + const resolve: (() => void)[] = [] + const asyncCountAtom = atom( + async (get) => { + await new Promise((r) => resolve.push(r)) + return get(countAtom) + }, + async (get, set) => { + await new Promise((r) => resolve.push(r)) + set(countAtom, get(countAtom) + 1) + } + ) + + const Counter = () => { + const [count, incCount] = useAtom(asyncCountAtom) + useEffect(() => { + incCount() + }, [incCount]) + return
count: {count}
+ } + + const Parent = () => { + const [toggle, setToggle] = useAtom(toggleAtom) + return ( + <> + + {toggle ? :
no child
} + + ) + } + + const { getByText, findByText } = render( + <> + + + + + ) + + await findByText('loading') + + resolve.splice(0).forEach((fn) => fn()) + await waitFor(() => { + resolve.splice(0).forEach((fn) => fn()) + getByText('count: 1') + }) + + fireEvent.click(getByText('button')) + await findByText('no child') + + fireEvent.click(getByText('button')) + await waitFor(() => { + resolve.splice(0).forEach((fn) => fn()) + getByText('count: 2') + }) +}) + +it('async get and useEffect on parent', async () => { + const countAtom = atom(0) + const asyncAtom = atom(async (get) => { + const count = get(countAtom) + if (!count) return 'none' + return 'resolved' + }) + + const AsyncComponent = () => { + const [text] = useAtom(asyncAtom) + return
text: {text}
+ } + + const Parent = () => { + const [count, setCount] = useAtom(countAtom) + useEffect(() => { + setCount((c) => c + 1) + }, [setCount]) + return ( + <> +
count: {count}
+ + + + ) + } + + const { getByText, findByText } = render( + <> + + + + + ) + + await findByText('loading') + await waitFor(() => { + getByText('count: 1') + getByText('text: resolved') + }) +}) + +it('async get with another dep and useEffect on parent', async () => { + const countAtom = atom(0) + const derivedAtom = atom((get) => get(countAtom)) + const asyncAtom = atom(async (get) => { + const count = get(derivedAtom) + if (!count) return 'none' + return count + }) + + const AsyncComponent = () => { + const [count] = useAtom(asyncAtom) + return
async: {count}
+ } + + const Parent = () => { + const [count, setCount] = useAtom(countAtom) + useEffect(() => { + setCount((c) => c + 1) + }, [setCount]) + return ( + <> +
count: {count}
+ + + + ) + } + + const { getByText, findByText } = render( + <> + + + + + ) + + await findByText('loading') + await waitFor(() => { + getByText('count: 1') + getByText('async: 1') + }) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 2') + getByText('async: 2') + }) +}) + +it('set promise atom value on write (#304)', async () => { + const countAtom = atom(Promise.resolve(0)) + let resolve = () => {} + const asyncAtom = atom(null, (get, set, _arg) => { + set( + countAtom, + Promise.resolve(get(countAtom)).then( + (c) => new Promise((r) => (resolve = () => r(c + 1))) + ) + ) + }) + + const Counter = () => { + const [count] = useAtom(countAtom) + return
count: {count * 1}
+ } + + const Parent = () => { + const [, dispatch] = useAtom(asyncAtom) + return ( + <> + + + + ) + } + + const { getByText, findByText } = render( + + + + + + ) + + await findByText('loading') + await findByText('count: 0') + + fireEvent.click(getByText('button')) + await findByText('loading') + resolve() + await findByText('count: 1') +}) + +it('uses async atom double chain (#306)', async () => { + const countAtom = atom(0) + let resolve = () => {} + const asyncCountAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)) + return get(countAtom) + }) + const delayedCountAtom = atom(async (get) => { + return get(asyncCountAtom) + }) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const [delayedCount] = useAtom(delayedCountAtom) + return ( + <> +
+ count: {count}, delayed: {delayedCount} +
+ + + ) + } + + const { getByText, findByText } = render( + + + + + + ) + + await findByText('loading') + resolve() + await findByText('count: 0, delayed: 0') + + fireEvent.click(getByText('button')) + await findByText('loading') + resolve() + await findByText('count: 1, delayed: 1') +}) + +it('uses an async atom that depends on another async atom', async () => { + let resolve = () => {} + const asyncAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)) + get(anotherAsyncAtom) + return 1 + }) + const anotherAsyncAtom = atom(async () => { + return 2 + }) + + const Counter = () => { + const [num] = useAtom(asyncAtom) + return
num: {num}
+ } + + const { findByText } = render( + + + + + + ) + + await findByText('loading') + resolve() + await findByText('num: 1') +}) + +it('a derived atom from a newly created async atom (#351)', async () => { + const countAtom = atom(1) + const atomCache = new Map>>() + const getAsyncAtom = (n: number) => { + if (!atomCache.has(n)) { + atomCache.set( + n, + atom(async () => { + return n + 10 + }) + ) + } + return atomCache.get(n) as Atom> + } + const derivedAtom = atom((get) => get(getAsyncAtom(get(countAtom)))) + + const Counter = () => { + const [, setCount] = useAtom(countAtom) + const [derived] = useAtom(derivedAtom) + return ( + <> +
+ derived: {derived}, commits: {useCommitCount()} +
+ + + ) + } + + const { getByText, findByText } = render( + <> + + + + + ) + + await findByText('loading') + await findByText('derived: 11, commits: 1') + + fireEvent.click(getByText('button')) + await findByText('loading') + await findByText('derived: 12, commits: 2') + + fireEvent.click(getByText('button')) + await findByText('loading') + await findByText('derived: 13, commits: 3') +}) + +it('Handles synchronously invoked async set (#375)', async () => { + const loadingAtom = atom(false) + const documentAtom = atom(undefined) + let resolve = () => {} + const loadDocumentAtom = atom(null, (_get, set) => { + const fetch = async () => { + set(loadingAtom, true) + const response = await new Promise( + (r) => (resolve = () => r('great document')) + ) + set(documentAtom, response) + set(loadingAtom, false) + } + fetch() + }) + + const ListDocuments = () => { + const [loading] = useAtom(loadingAtom) + const [document] = useAtom(documentAtom) + const [, loadDocument] = useAtom(loadDocumentAtom) + + useEffect(() => { + loadDocument() + }, [loadDocument]) + + return ( + <> + {loading &&
loading
} + {!loading &&
{document}
} + + ) + } + + const { findByText } = render( + + + + ) + + await findByText('loading') + resolve() + await findByText('great document') +}) + +it('async write self atom', async () => { + let resolve = () => {} + const countAtom = atom(0, async (get, set, _arg) => { + set(countAtom, get(countAtom) + 1) + await new Promise((r) => (resolve = r)) + set(countAtom, -1) + }) + + const Counter = () => { + const [count, inc] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('button')) + resolve() + await findByText('count: -1') +}) + +it('non suspense async write self atom with setTimeout (#389)', async () => { + const countAtom = atom(0, (get, set, _arg) => { + set(countAtom, get(countAtom) + 1) + setTimeout(() => set(countAtom, -1)) + }) + + const Counter = () => { + const [count, inc] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('button')) + await findByText('count: 1') + await findByText('count: -1') +}) + +it('should override promise as atom value (#430)', async () => { + const countAtom = atom(new Promise(() => {})) + const setCountAtom = atom(null, (_get, set, arg: number) => { + set(countAtom, Promise.resolve(arg)) + }) + + const Counter = () => { + const [count] = useAtom(countAtom) + return
count: {count * 1}
+ } + + const Control = () => { + const [, setCount] = useAtom(setCountAtom) + return + } + + const { getByText, findByText } = render( + + + + + + + ) + + await findByText('loading') + + fireEvent.click(getByText('button')) + await findByText('count: 1') +}) + +it('combine two promise atom values (#442)', async () => { + const count1Atom = atom(new Promise(() => {})) + const count2Atom = atom(new Promise(() => {})) + const derivedAtom = atom( + async (get) => (await get(count1Atom)) + (await get(count2Atom)) + ) + const initAtom = atom(null, (_get, set) => { + setTimeout(() => set(count1Atom, Promise.resolve(1))) + setTimeout(() => set(count2Atom, Promise.resolve(2))) + }) + initAtom.onMount = (init) => { + init() + } + + const Counter = () => { + const [count] = useAtom(derivedAtom) + return
count: {count}
+ } + + const Control = () => { + useAtom(initAtom) + return null + } + + const { findByText } = render( + + + + + + + ) + + await findByText('loading') + await findByText('count: 3') +}) + +it('set two promise atoms at once', async () => { + const count1Atom = atom(new Promise(() => {})) + const count2Atom = atom(new Promise(() => {})) + const derivedAtom = atom( + async (get) => (await get(count1Atom)) + (await get(count2Atom)) + ) + const setCountsAtom = atom(null, (_get, set) => { + set(count1Atom, Promise.resolve(1)) + set(count2Atom, Promise.resolve(2)) + }) + + const Counter = () => { + const [count] = useAtom(derivedAtom) + return
count: {count}
+ } + + const Control = () => { + const [, setCounts] = useAtom(setCountsAtom) + return + } + + const { getByText, findByText } = render( + + + + + + + ) + + await findByText('loading') + + fireEvent.click(getByText('button')) + await findByText('count: 3') +}) + +it('async write chain', async () => { + const countAtom = atom(0) + let resolve1 = () => {} + const asyncWriteAtom = atom(null, async (_get, set, _arg) => { + await new Promise((r) => (resolve1 = r)) + set(countAtom, 2) + }) + let resolve2 = () => {} + const controlAtom = atom(null, async (_get, set, _arg) => { + set(countAtom, 1) + await set(asyncWriteAtom, null) + await new Promise((r) => (resolve2 = r)) + set(countAtom, 3) + }) + + const Counter = () => { + const [count] = useAtom(countAtom) + return
count: {count}
+ } + + const Control = () => { + const [, invoke] = useAtom(controlAtom) + return + } + + const { getByText, findByText } = render( + + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('button')) + await findByText('count: 1') + resolve1() + await findByText('count: 2') + resolve2() + await findByText('count: 3') +}) + +it('async atom double chain without setTimeout (#751)', async () => { + const enabledAtom = atom(false) + let resolve = () => {} + const asyncAtom = atom(async (get) => { + const enabled = get(enabledAtom) + if (!enabled) { + return 'init' + } + await new Promise((r) => (resolve = r)) + return 'ready' + }) + const derivedAsyncAtom = atom(async (get) => get(asyncAtom)) + const anotherAsyncAtom = atom(async (get) => get(derivedAsyncAtom)) + + const AsyncComponent = () => { + const [text] = useAtom(anotherAsyncAtom) + return
async: {text}
+ } + + const Parent = () => { + // Use useAtom to reproduce the issue + const [, setEnabled] = useAtom(enabledAtom) + return ( + <> + + + + + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('async: init') + + fireEvent.click(getByText('button')) + await findByText('loading') + resolve() + await findByText('async: ready') +}) + +it('async atom double chain with setTimeout', async () => { + const enabledAtom = atom(false) + const resolve: (() => void)[] = [] + const asyncAtom = atom(async (get) => { + const enabled = get(enabledAtom) + if (!enabled) { + return 'init' + } + await new Promise((r) => resolve.push(r)) + return 'ready' + }) + const derivedAsyncAtom = atom(async (get) => { + await new Promise((r) => resolve.push(r)) + return get(asyncAtom) + }) + const anotherAsyncAtom = atom(async (get) => { + await new Promise((r) => resolve.push(r)) + return get(derivedAsyncAtom) + }) + + const AsyncComponent = () => { + const [text] = useAtom(anotherAsyncAtom) + return
async: {text}
+ } + + const Parent = () => { + // Use useAtom to reproduce the issue + const [, setEnabled] = useAtom(enabledAtom) + return ( + <> + + + + + + ) + } + + const { getByText, findByText } = render( + + + + ) + + resolve.splice(0).forEach((fn) => fn()) + await findByText('loading') + + resolve.splice(0).forEach((fn) => fn()) + await findByText('async: init') + + fireEvent.click(getByText('button')) + await findByText('loading') + resolve.splice(0).forEach((fn) => fn()) + await findByText('async: ready') +}) + +it('update unmounted async atom with intermediate atom', async () => { + const enabledAtom = atom(true) + const countAtom = atom(1) + + const resolve: (() => void)[] = [] + const intermediateAtom = atom((get) => { + const count = get(countAtom) + const enabled = get(enabledAtom) + const tmpAtom = atom(async () => { + if (!enabled) { + return -1 + } + await new Promise((r) => resolve.push(r)) + return count * 2 + }) + return tmpAtom + }) + const derivedAtom = atom((get) => { + const tmpAtom = get(intermediateAtom) + return get(tmpAtom) + }) + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + const Control = () => { + const [, setEnabled] = useAtom(enabledAtom) + const [, setCount] = useAtom(countAtom) + return ( + <> + + + + ) + } + + const { getByText, findByText } = render( + + + + + + + ) + + await findByText('loading') + resolve.splice(0).forEach((fn) => fn()) + await findByText('derived: 2') + + fireEvent.click(getByText('toggle enabled')) + fireEvent.click(getByText('increment count')) + await findByText('derived: -1') + + fireEvent.click(getByText('toggle enabled')) + await findByText('loading') + resolve.splice(0).forEach((fn) => fn()) + await findByText('derived: 4') +}) + +it('multiple derived atoms with dependency chaining and async write (#813)', async () => { + const responseBaseAtom = atom<{ name: string }[] | null>(null) + + const response1 = [{ name: 'alpha' }, { name: 'beta' }] + const responseAtom = atom( + (get) => get(responseBaseAtom), + (_get, set) => { + setTimeout(() => set(responseBaseAtom, response1)) + } + ) + responseAtom.onMount = (init) => { + init() + } + + const mapAtom = atom((get) => get(responseAtom)) + const itemA = atom((get) => get(mapAtom)?.[0]) + const itemB = atom((get) => get(mapAtom)?.[1]) + const itemAName = atom((get) => get(itemA)?.name) + const itemBName = atom((get) => get(itemB)?.name) + + const App = () => { + const [aName] = useAtom(itemAName) + const [bName] = useAtom(itemBName) + return ( + <> +
aName: {aName}
+
bName: {bName}
+ + ) + } + + const { getByText } = render( + + + + ) + + await waitFor(() => { + getByText('aName: alpha') + getByText('bName: beta') + }) +}) diff --git a/tests/react/async2.test.tsx b/tests/react/async2.test.tsx new file mode 100644 index 0000000000..020c3b4168 --- /dev/null +++ b/tests/react/async2.test.tsx @@ -0,0 +1,135 @@ +import { StrictMode, Suspense } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { useAtomValue, useSetAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' + +describe('useAtom delay option test', () => { + it('suspend for Promise.resovle without delay option', async () => { + const countAtom = atom(0) + const asyncAtom = atom((get) => { + const count = get(countAtom) + if (count === 0) { + return 0 + } + return Promise.resolve(count) + }) + + const Component = () => { + const count = useAtomValue(asyncAtom) + return
count: {count}
+ } + + const Controls = () => { + const setCount = useSetAtom(countAtom) + return ( + <> + + + ) + } + + const { getByText, findByText } = render( + + + + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('button')) + await findByText('loading') + await findByText('count: 1') + }) + + it('do not suspend for Promise.resovle with delay option', async () => { + const countAtom = atom(0) + const asyncAtom = atom((get) => { + const count = get(countAtom) + if (count === 0) { + return 0 + } + return Promise.resolve(count) + }) + + const Component = () => { + const count = useAtomValue(asyncAtom, { delay: 0 }) + return
count: {count}
+ } + + const Controls = () => { + const setCount = useSetAtom(countAtom) + return ( + <> + + + ) + } + + const { getByText, findByText } = render( + + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('button')) + await findByText('count: 1') + }) +}) + +describe('atom read function retry option test', () => { + it('do not suspend with promise resolving with retry', async () => { + const countAtom = atom(0) + let resolve = () => {} + const asyncAtom = atom(async () => { + await new Promise((r) => (resolve = r)) + return 'hello' + }) + const promiseCache = new WeakMap() + const derivedAtom = atom((get, { retry }) => { + const count = get(countAtom) + const promise = get(asyncAtom) + if (promiseCache.has(promise)) { + return promiseCache.get(promise) + count + } + promise.then((v) => { + promiseCache.set(promise, v) + retry() + }) + return 'pending' + count + }) + + const Component = () => { + const text = useAtomValue(derivedAtom) + return
text: {text}
+ } + + const Controls = () => { + const setCount = useSetAtom(countAtom) + return ( + <> + + + ) + } + + const { getByText, findByText } = render( + + + + + ) + + await findByText('text: pending0') + resolve() + await findByText('text: hello0') + + fireEvent.click(getByText('button')) + await findByText('text: hello1') + }) +}) diff --git a/tests/react/basic.test.tsx b/tests/react/basic.test.tsx new file mode 100644 index 0000000000..c902cbea4e --- /dev/null +++ b/tests/react/basic.test.tsx @@ -0,0 +1,1000 @@ +import { + StrictMode, + Suspense, + version as reactVersion, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { unstable_batchedUpdates } from 'react-dom' +import { useAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' +import type { PrimitiveAtom } from 'jotai/vanilla' + +const IS_REACT18 = /^18\./.test(reactVersion) + +const batchedUpdates = (fn: () => void) => { + if (IS_REACT18) { + fn() + } else { + unstable_batchedUpdates(fn) + } +} + +const useCommitCount = () => { + const commitCountRef = useRef(1) + useEffect(() => { + commitCountRef.current += 1 + }) + return commitCountRef.current +} + +it('uses a primitive atom', async () => { + const countAtom = atom(0) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('button')) + await findByText('count: 1') +}) + +it('uses a read-only derived atom', async () => { + const countAtom = atom(0) + const doubledCountAtom = atom((get) => get(countAtom) * 2) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const [doubledCount] = useAtom(doubledCountAtom) + return ( + <> +
count: {count}
+
doubledCount: {doubledCount}
+ + + ) + } + + const { getByText } = render( + + + + ) + + await waitFor(() => { + getByText('count: 0') + getByText('doubledCount: 0') + }) + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 1') + getByText('doubledCount: 2') + }) +}) + +it('uses a read-write derived atom', async () => { + const countAtom = atom(0) + const doubledCountAtom = atom( + (get) => get(countAtom) * 2, + (get, set, update: number) => set(countAtom, get(countAtom) + update) + ) + + const Counter = () => { + const [count] = useAtom(countAtom) + const [doubledCount, increaseCount] = useAtom(doubledCountAtom) + return ( + <> +
count: {count}
+
doubledCount: {doubledCount}
+ + + ) + } + + const { getByText } = render( + + + + ) + + await waitFor(() => { + getByText('count: 0') + getByText('doubledCount: 0') + }) + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 2') + getByText('doubledCount: 4') + }) +}) + +it('uses a write-only derived atom', async () => { + const countAtom = atom(0) + const incrementCountAtom = atom(null, (get, set) => + set(countAtom, get(countAtom) + 1) + ) + + const Counter = () => { + const [count] = useAtom(countAtom) + return ( +
+ commits: {useCommitCount()}, count: {count} +
+ ) + } + + const Control = () => { + const [, increment] = useAtom(incrementCountAtom) + return ( + <> +
button commits: {useCommitCount()}
+ + + ) + } + + const { getByText } = render( + <> + + + + ) + + await waitFor(() => { + getByText('commits: 1, count: 0') + getByText('button commits: 1') + }) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('commits: 2, count: 1') + getByText('button commits: 1') + }) +}) + +it('only re-renders if value has changed', async () => { + const count1Atom = atom(0) + const count2Atom = atom(0) + const productAtom = atom((get) => get(count1Atom) * get(count2Atom)) + + type Props = { countAtom: typeof count1Atom; name: string } + const Counter = ({ countAtom, name }: Props) => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
+ commits: {useCommitCount()}, {name}: {count} +
+ + + ) + } + + const Product = () => { + const [product] = useAtom(productAtom) + return ( + <> +
+ commits: {useCommitCount()}, product: {product} +
+ + ) + } + + const { getByText } = render( + <> + + + + + ) + + await waitFor(() => { + getByText('commits: 1, count1: 0') + getByText('commits: 1, count2: 0') + getByText('commits: 1, product: 0') + }) + fireEvent.click(getByText('button-count1')) + await waitFor(() => { + getByText('commits: 2, count1: 1') + getByText('commits: 1, count2: 0') + getByText('commits: 1, product: 0') + }) + fireEvent.click(getByText('button-count2')) + await waitFor(() => { + getByText('commits: 2, count1: 1') + getByText('commits: 2, count2: 1') + getByText('commits: 2, product: 1') + }) +}) + +it('re-renders a time delayed derived atom with the same initial value (#947)', async () => { + const aAtom = atom(false) + aAtom.onMount = (set) => { + setTimeout(() => { + set(true) + }) + } + + const bAtom = atom(1) + bAtom.onMount = (set) => { + set(2) + } + + const cAtom = atom((get) => { + if (get(aAtom)) { + return get(bAtom) + } + return 1 + }) + + const App = () => { + const [value] = useAtom(cAtom) + return <>{value} + } + + const { findByText } = render( + + + + ) + + await findByText('2') +}) + +it('works with async get', async () => { + const countAtom = atom(0) + let resolve = () => {} + const asyncCountAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)) + return get(countAtom) + }) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const [delayedCount] = useAtom(asyncCountAtom) + return ( + <> +
+ commits: {useCommitCount()}, count: {count}, delayedCount:{' '} + {delayedCount} +
+ + + ) + } + + const { getByText, findByText } = render( + <> + + + + + ) + + await findByText('loading') + resolve() + await findByText('commits: 1, count: 0, delayedCount: 0') + + fireEvent.click(getByText('button')) + await findByText('loading') + resolve() + await findByText('commits: 2, count: 1, delayedCount: 1') + + fireEvent.click(getByText('button')) + await findByText('loading') + resolve() + await findByText('commits: 3, count: 2, delayedCount: 2') +}) + +it('works with async get without setTimeout', async () => { + const countAtom = atom(0) + const asyncCountAtom = atom(async (get) => { + return get(countAtom) + }) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const [delayedCount] = useAtom(asyncCountAtom) + return ( + <> +
+ count: {count}, delayedCount: {delayedCount} +
+ + + ) + } + + const { getByText, findByText } = render( + + + + + + ) + + await findByText('loading') + await findByText('count: 0, delayedCount: 0') + + fireEvent.click(getByText('button')) + await findByText('count: 1, delayedCount: 1') + + fireEvent.click(getByText('button')) + await findByText('count: 2, delayedCount: 2') +}) + +it('uses atoms with tree dependencies', async () => { + const topAtom = atom(0) + const leftAtom = atom((get) => get(topAtom)) + let resolve = () => {} + const rightAtom = atom( + (get) => get(topAtom), + async (get, set, update: (prev: number) => number) => { + await new Promise((r) => (resolve = r)) + batchedUpdates(() => { + set(topAtom, update(get(topAtom))) + }) + } + ) + + const Counter = () => { + const [count] = useAtom(leftAtom) + const [, setCount] = useAtom(rightAtom) + return ( + <> +
+ commits: {useCommitCount()}, count: {count} +
+ + + ) + } + + const { getByText, findByText } = render( + <> + + + ) + + await findByText('commits: 1, count: 0') + + fireEvent.click(getByText('button')) + resolve() + await findByText('commits: 2, count: 1') + + fireEvent.click(getByText('button')) + resolve() + await findByText('commits: 3, count: 2') +}) + +it('runs update only once in StrictMode', async () => { + let updateCount = 0 + const countAtom = atom(0) + const derivedAtom = atom( + (get) => get(countAtom), + (_get, set, update: number) => { + updateCount += 1 + set(countAtom, update) + } + ) + + const Counter = () => { + const [count, setCount] = useAtom(derivedAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('count: 0') + expect(updateCount).toBe(0) + + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(updateCount).toBe(1) +}) + +it('uses an async write-only atom', async () => { + const countAtom = atom(0) + let resolve = () => {} + const asyncCountAtom = atom( + null, + async (get, set, update: (prev: number) => number) => { + await new Promise((r) => (resolve = r)) + set(countAtom, update(get(countAtom))) + } + ) + + const Counter = () => { + const [count] = useAtom(countAtom) + const [, setCount] = useAtom(asyncCountAtom) + return ( + <> +
+ commits: {useCommitCount()}, count: {count} +
+ + + ) + } + + const { getByText, findByText } = render( + <> + + + ) + + await findByText('commits: 1, count: 0') + + fireEvent.click(getByText('button')) + resolve() + await findByText('commits: 2, count: 1') +}) + +it('uses a writable atom without read function', async () => { + let resolve = () => {} + const countAtom = atom(1, async (get, set, v: number) => { + await new Promise((r) => (resolve = r)) + set(countAtom, get(countAtom) + 10 * v) + }) + + const Counter = () => { + const [count, addCount10Times] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('count: 1') + + fireEvent.click(getByText('button')) + resolve() + await findByText('count: 11') +}) + +it('can write an atom value on useEffect', async () => { + const countAtom = atom(0) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + useEffect(() => { + setCount((c) => c + 1) + }, [setCount]) + return
count: {count}
+ } + + const { findByText } = render( + <> + + + ) + + await findByText('count: 1') +}) + +it('can write an atom value on useEffect in children', async () => { + const countAtom = atom(0) + + const Child = ({ + setCount, + }: { + setCount: (f: (c: number) => number) => void + }) => { + useEffect(() => { + setCount((c) => c + 1) + }, [setCount]) + return null + } + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( +
+ count: {count} + + +
+ ) + } + + const { findByText } = render( + <> + + + ) + + await findByText('count: 2') +}) + +it('only invoke read function on use atom', async () => { + const countAtom = atom(0) + let readCount = 0 + const doubledCountAtom = atom((get) => { + readCount += 1 + return get(countAtom) * 2 + }) + + expect(readCount).toBe(0) // do not invoke on atom() + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const [doubledCount] = useAtom(doubledCountAtom) + return ( + <> +
+ commits: {useCommitCount()}, count: {count}, readCount: {readCount}, + doubled: {doubledCount} +
+ + + ) + } + + const { getByText, findByText } = render( + <> + + + ) + + await findByText('commits: 1, count: 0, readCount: 1, doubled: 0') + + fireEvent.click(getByText('button')) + await findByText('commits: 2, count: 1, readCount: 2, doubled: 2') +}) + +it('uses a read-write derived atom with two primitive atoms', async () => { + const countAAtom = atom(0) + const countBAtom = atom(0) + const sumAtom = atom( + (get) => get(countAAtom) + get(countBAtom), + (_get, set) => { + set(countAAtom, 0) + set(countBAtom, 0) + } + ) + const incBothAtom = atom(null, (get, set) => { + set(countAAtom, get(countAAtom) + 1) + set(countBAtom, get(countBAtom) + 1) + }) + + const Counter = () => { + const [countA, setCountA] = useAtom(countAAtom) + const [countB, setCountB] = useAtom(countBAtom) + const [sum, reset] = useAtom(sumAtom) + const [, incBoth] = useAtom(incBothAtom) + return ( + <> +
+ countA: {countA}, countB: {countB}, sum: {sum} +
+ + + + + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('countA: 0, countB: 0, sum: 0') + + fireEvent.click(getByText('incA')) + await findByText('countA: 1, countB: 0, sum: 1') + + fireEvent.click(getByText('incB')) + await findByText('countA: 1, countB: 1, sum: 2') + + fireEvent.click(getByText('reset')) + await findByText('countA: 0, countB: 0, sum: 0') + + fireEvent.click(getByText('incBoth')) + await findByText('countA: 1, countB: 1, sum: 2') +}) + +it('updates a derived atom in useEffect with two primitive atoms', async () => { + const countAAtom = atom(0) + const countBAtom = atom(1) + const sumAtom = atom((get) => get(countAAtom) + get(countBAtom)) + + const Counter = () => { + const [countA, setCountA] = useAtom(countAAtom) + const [countB, setCountB] = useAtom(countBAtom) + const [sum] = useAtom(sumAtom) + useEffect(() => { + setCountA((c) => c + 1) + }, [setCountA, countB]) + return ( + <> +
+ countA: {countA}, countB: {countB}, sum: {sum} +
+ + + ) + } + + const { getByText, findByText } = render( + <> + + + ) + + await findByText('countA: 1, countB: 1, sum: 2') + + fireEvent.click(getByText('button')) + await findByText('countA: 2, countB: 2, sum: 4') +}) + +it('updates two atoms in child useEffect', async () => { + const countAAtom = atom(0) + const countBAtom = atom(10) + + const Child = () => { + const [countB, setCountB] = useAtom(countBAtom) + useEffect(() => { + setCountB((c) => c + 1) + }, [setCountB]) + return
countB: {countB}
+ } + + const Counter = () => { + const [countA, setCountA] = useAtom(countAAtom) + useEffect(() => { + setCountA((c) => c + 1) + }, [setCountA]) + return ( + <> +
countA: {countA}
+ {countA > 0 && } + + ) + } + + const { getByText } = render( + <> + + + ) + + await waitFor(() => { + getByText('countA: 1') + getByText('countB: 11') + }) +}) + +it('set atom right after useEffect (#208)', async () => { + const countAtom = atom(0) + const effectFn = jest.fn() + + const Child = () => { + const [count, setCount] = useAtom(countAtom) + const [, setState] = useState(null) + // rAF does not repro, so schedule update intentionally in render + if (count === 1) { + Promise.resolve().then(() => { + setCount(2) + }) + } + useEffect(() => { + effectFn(count) + setState(null) // this is important to repro (set something stable) + }, [count, setState]) + return
count: {count}
+ } + + const Parent = () => { + const [, setCount] = useAtom(countAtom) + useEffect(() => { + setCount(1) + // requestAnimationFrame(() => setCount(2)) + }, [setCount]) + return + } + + const { findByText } = render( + + + + ) + + await findByText('count: 2') + expect(effectFn).lastCalledWith(2) +}) + +it('changes atom from parent (#273, #275)', async () => { + const atomA = atom({ id: 'a' }) + const atomB = atom({ id: 'b' }) + + const Item = ({ id }: { id: string }) => { + const a = useMemo(() => (id === 'a' ? atomA : atomB), [id]) + const [atomValue] = useAtom(a) + return ( +
+ commits: {useCommitCount()}, id: {atomValue.id} +
+ ) + } + + const App = () => { + const [id, setId] = useState('a') + return ( +
+ + + +
+ ) + } + + const { getByText, findByText } = render( + <> + + + ) + + await findByText('commits: 1, id: a') + + fireEvent.click(getByText('atom a')) + await findByText('commits: 1, id: a') + + fireEvent.click(getByText('atom b')) + await findByText('commits: 2, id: b') + + fireEvent.click(getByText('atom a')) + await findByText('commits: 3, id: a') +}) + +it('should be able to use a double derived atom twice and useEffect (#373)', async () => { + const countAtom = atom(0) + const doubleAtom = atom((get) => get(countAtom) * 2) + const fourfoldAtom = atom((get) => get(doubleAtom) * 2) + + const App = () => { + const [count, setCount] = useAtom(countAtom) + const [fourfold] = useAtom(fourfoldAtom) + const [fourfold2] = useAtom(fourfoldAtom) + + useEffect(() => { + setCount(count) + }, [count, setCount]) + + return ( +
+ count: {count},{fourfold},{fourfold2} + +
+ ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('count: 0,0,0') + fireEvent.click(getByText('one up')) + await findByText('count: 1,4,4') +}) + +it('write self atom (undocumented usage)', async () => { + const countAtom = atom(0, (get, set, _arg) => { + set(countAtom, get(countAtom) + 1) + }) + + const Counter = () => { + const [count, inc] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('button')) + await findByText('count: 1') +}) + +it('async chain for multiple sync and async atoms (#443)', async () => { + const num1Atom = atom(async () => { + return 1 + }) + const num2Atom = atom(async () => { + return 2 + }) + + // "async" is required to reproduce the issue + const sumAtom = atom( + async (get) => (await get(num1Atom)) + (await get(num2Atom)) + ) + const countAtom = atom((get) => get(sumAtom)) + + const Counter = () => { + const [count] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + ) + } + const { findByText } = render( + + + + + + ) + + await findByText('loading') + await findByText('count: 3') +}) + +it('sync re-renders with useState re-renders (#827)', async () => { + const atom0 = atom('atom0') + const atom1 = atom('atom1') + const atom2 = atom('atom2') + const atoms = [atom0, atom1, atom2] + + const App = () => { + const [currentAtomIndex, setCurrentAtomIndex] = useState(0) + const rotateAtoms = () => { + setCurrentAtomIndex((prev) => (prev + 1) % atoms.length) + } + const [atomValue] = useAtom(atoms[currentAtomIndex] as typeof atoms[number]) + + return ( + <> + commits: {useCommitCount()} +

{atomValue}

+ + + ) + } + const { findByText, getByText } = render( + <> + + + ) + + await findByText('commits: 1') + fireEvent.click(getByText('rotate')) + await findByText('commits: 2') + fireEvent.click(getByText('rotate')) + await findByText('commits: 3') +}) + +it('chained derive atom with onMount and useEffect (#897)', async () => { + const countAtom = atom(0) + countAtom.onMount = (set) => { + set(1) + } + const derivedAtom = atom((get) => get(countAtom)) + const derivedObjectAtom = atom((get) => ({ + count: get(derivedAtom), + })) + + const Counter = () => { + const [, setCount] = useAtom(countAtom) + const [{ count }] = useAtom(derivedObjectAtom) + useEffect(() => { + setCount(1) + }, [setCount]) + return
count: {count}
+ } + + const { findByText } = render( + + + + ) + + await findByText('count: 1') +}) + +it('onMount is not called when atom value is accessed from writeGetter in derived atom (#942)', async () => { + const onUnmount = jest.fn() + const onMount = jest.fn(() => { + return onUnmount + }) + + const aAtom = atom(false) + aAtom.onMount = onMount + + const bAtom = atom(null, (get) => { + get(aAtom) + }) + + const App = () => { + const [, action] = useAtom(bAtom) + useEffect(() => action(), [action]) + return null + } + + render( + + + + ) + + expect(onMount).not.toBeCalled() + expect(onUnmount).not.toBeCalled() +}) + +it('useAtom returns consistent value with input with changing atoms (#1235)', async () => { + const countAtom = atom(0) + const valueAtoms = [atom(0), atom(1)] + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const [value] = useAtom(valueAtoms[count] as PrimitiveAtom) + if (count !== value) { + throw new Error('value mismatch') + } + return ( + <> +
count: {count}
+ + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('button')) + await findByText('count: 1') +}) diff --git a/tests/react/dependency.test.tsx b/tests/react/dependency.test.tsx new file mode 100644 index 0000000000..9519b00328 --- /dev/null +++ b/tests/react/dependency.test.tsx @@ -0,0 +1,908 @@ +import { StrictMode, Suspense, useEffect, useRef, useState } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useAtom, useAtomValue, useSetAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' + +const useCommitCount = () => { + const commitCountRef = useRef(1) + useEffect(() => { + commitCountRef.current += 1 + }) + return commitCountRef.current +} + +it('works with 2 level dependencies', async () => { + const countAtom = atom(1) + const doubledAtom = atom((get) => get(countAtom) * 2) + const tripledAtom = atom((get) => get(doubledAtom) * 3) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const [doubledCount] = useAtom(doubledAtom) + const [tripledCount] = useAtom(tripledAtom) + return ( + <> +
+ commits: {useCommitCount()}, count: {count}, doubled: {doubledCount}, + tripled: {tripledCount} +
+ + + ) + } + + const { getByText, findByText } = render( + <> + + + ) + + await findByText('commits: 1, count: 1, doubled: 2, tripled: 6') + + fireEvent.click(getByText('button')) + await findByText('commits: 2, count: 2, doubled: 4, tripled: 12') +}) + +it('works a primitive atom and a dependent async atom', async () => { + const countAtom = atom(1) + let resolve = () => {} + const doubledAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)) + return get(countAtom) * 2 + }) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const [doubledCount] = useAtom(doubledAtom) + return ( + <> +
+ count: {count}, doubled: {doubledCount} +
+ + + ) + } + + const { getByText, findByText } = render( + + + + + + ) + + await findByText('loading') + resolve() + await findByText('count: 1, doubled: 2') + + fireEvent.click(getByText('button')) + await findByText('loading') + resolve() + await findByText('count: 2, doubled: 4') + + fireEvent.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().mockImplementation((get) => get(countAtom)) + const derivedAtom = atom(derivedFn) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + const Parent = () => { + const [show, setShow] = useState(true) + return ( +
+ + {show ? ( + <> + + + + ) : ( +
hidden
+ )} +
+ ) + } + + const { getByText } = render( + + + + ) + + await waitFor(() => { + getByText('count: 0') + getByText('derived: 0') + }) + expect(derivedFn).toHaveReturnedTimes(1) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 1') + getByText('derived: 1') + }) + expect(derivedFn).toHaveReturnedTimes(2) + + fireEvent.click(getByText('toggle')) + await waitFor(() => { + getByText('hidden') + }) + expect(derivedFn).toHaveReturnedTimes(2) + + fireEvent.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().mockImplementation((get) => get(countAtom)) + const derivedAtom = atom(derivedFn) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + const Parent = () => { + const [showDerived, setShowDerived] = useState(true) + return ( +
+ + {showDerived ? : } +
+ ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('derived: 0') + expect(derivedFn).toHaveReturnedTimes(1) + + fireEvent.click(getByText('toggle')) + await findByText('count: 0') + expect(derivedFn).toHaveReturnedTimes(1) + + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(derivedFn).toHaveReturnedTimes(1) + + fireEvent.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().mockImplementation((get) => get(countAtom)) + const derivedAtom = atom(derivedFn) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + const { getByText } = render( + + + + + ) + + await waitFor(() => { + getByText('count: 0') + getByText('derived: 0') + }) + expect(derivedFn).toHaveReturnedTimes(1) + + fireEvent.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() + .mockImplementation((get) => get(dataAtom).count) + const countAtom = atom(getDataCountFn) + const getDataObjFn = jest.fn().mockImplementation((get) => get(dataAtom).obj) + const objAtom = atom(getDataObjFn) + const getAnotherCountFn = jest + .fn() + .mockImplementation((get) => get(objAtom).anotherCount) + const anotherCountAtom = atom(getAnotherCountFn) + + const Counter = () => { + const [count] = useAtom(countAtom) + const [, setData] = useAtom(dataAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const DerivedCounter = () => { + const [anotherCount] = useAtom(anotherCountAtom) + return
anotherCount: {anotherCount}
+ } + + const { getByText } = render( + + + + + ) + + await waitFor(() => { + getByText('count: 1') + getByText('anotherCount: 10') + }) + expect(getDataCountFn).toHaveReturnedTimes(1) + expect(getDataObjFn).toHaveReturnedTimes(1) + expect(getAnotherCountFn).toHaveReturnedTimes(1) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 2') + getByText('anotherCount: 10') + }) + expect(getDataCountFn).toHaveReturnedTimes(2) + expect(getDataObjFn).toHaveReturnedTimes(2) + expect(getAnotherCountFn).toHaveReturnedTimes(1) +}) + +it('derived atom to update base atom in callback', async () => { + const countAtom = atom(1) + const doubledAtom = atom( + (get) => get(countAtom) * 2, + (_get, _set, callback: () => void) => { + callback() + } + ) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const [doubledCount, dispatch] = useAtom(doubledAtom) + return ( + <> +
+ commits: {useCommitCount()}, count: {count}, doubled: {doubledCount} +
+ + + ) + } + + const { getByText, findByText } = render( + <> + + + ) + + await findByText('commits: 1, count: 1, doubled: 2') + + fireEvent.click(getByText('button')) + await findByText('commits: 2, count: 2, doubled: 4') +}) + +it('can read sync derived atom in write without initializing', async () => { + const countAtom = atom(1) + const doubledAtom = atom((get) => get(countAtom) * 2) + const addAtom = atom(null, (get, set, num: number) => { + set(countAtom, get(doubledAtom) / 2 + num) + }) + + const Counter = () => { + const [count] = useAtom(countAtom) + const [, add] = useAtom(addAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('count: 1') + + fireEvent.click(getByText('button')) + await findByText('count: 2') + + fireEvent.click(getByText('button')) + await findByText('count: 3') +}) + +it('can remount atoms with dependency (#490)', async () => { + const countAtom = atom(0) + const derivedAtom = atom((get) => get(countAtom)) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + const Parent = () => { + const [showChildren, setShowChildren] = useState(true) + return ( +
+ + {showChildren ? ( + <> + + + + ) : ( +
hidden
+ )} +
+ ) + } + + const { getByText } = render( + + + + ) + + await waitFor(() => { + getByText('count: 0') + getByText('derived: 0') + }) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 1') + getByText('derived: 1') + }) + + fireEvent.click(getByText('toggle')) + await waitFor(() => { + getByText('hidden') + }) + + fireEvent.click(getByText('toggle')) + await waitFor(() => { + getByText('count: 1') + getByText('derived: 1') + }) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 2') + getByText('derived: 2') + }) +}) + +it('can remount atoms with intermediate atom', async () => { + const countAtom = atom(1) + + const resultAtom = atom(0) + const intermediateAtom = atom((get) => { + const count = get(countAtom) + const initAtom = atom(null, (_get, set) => { + set(resultAtom, count * 2) + }) + initAtom.onMount = (init) => { + init() + } + return initAtom + }) + const derivedAtom = atom((get) => { + const initAtom = get(intermediateAtom) + get(initAtom) + return get(resultAtom) + }) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + const Parent = () => { + const [showChildren, setShowChildren] = useState(true) + return ( +
+ + + {showChildren ? :
hidden
} +
+ ) + } + + const { getByText } = render( + + + + ) + + await waitFor(() => { + getByText('count: 1') + getByText('derived: 2') + }) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 2') + getByText('derived: 4') + }) + + fireEvent.click(getByText('toggle')) + await waitFor(() => { + getByText('count: 2') + getByText('hidden') + }) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 3') + getByText('hidden') + }) + + fireEvent.click(getByText('toggle')) + await waitFor(() => { + getByText('count: 3') + getByText('derived: 6') + }) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 4') + getByText('derived: 8') + }) +}) + +it('can update dependents with useEffect (#512)', async () => { + const enabledAtom = atom(false) + const countAtom = atom(1) + + const derivedAtom = atom((get) => { + const enabled = get(enabledAtom) + if (!enabled) { + return 0 + } + const count = get(countAtom) + return count * 2 + }) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + const Parent = () => { + const [, setEnabled] = useAtom(enabledAtom) + useEffect(() => { + setEnabled(true) + }, [setEnabled]) + return ( +
+ + +
+ ) + } + + const { getByText } = render( + + + + ) + + await waitFor(() => { + getByText('count: 1') + getByText('derived: 2') + }) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 2') + getByText('derived: 4') + }) +}) + +it('update unmounted atom with intermediate atom', async () => { + const enabledAtom = atom(true) + const countAtom = atom(1) + + const intermediateAtom = atom((get) => { + const count = get(countAtom) + const enabled = get(enabledAtom) + const tmpAtom = atom(enabled ? count * 2 : -1) + return tmpAtom + }) + const derivedAtom = atom((get) => { + const tmpAtom = get(intermediateAtom) + return get(tmpAtom) + }) + + const DerivedCounter = () => { + const [derived] = useAtom(derivedAtom) + return
derived: {derived}
+ } + + const Control = () => { + const [, setEnabled] = useAtom(enabledAtom) + const [, setCount] = useAtom(countAtom) + return ( + <> + + + + ) + } + + const { getByText, findByText } = render( + + + + + ) + + await findByText('derived: 2') + + fireEvent.click(getByText('toggle enabled')) + fireEvent.click(getByText('increment count')) + await findByText('derived: -1') + + fireEvent.click(getByText('toggle enabled')) + await findByText('derived: 4') +}) + +it('Should bail for derived sync chains (#877)', async () => { + let syncAtomCount = 0 + const textAtom = atom('hello') + + const syncAtom = atom((get) => { + get(textAtom) + syncAtomCount++ + return 'My very long data' + }) + + const derivedAtom = atom((get) => { + return get(syncAtom) + }) + + const Input = () => { + const [result] = useAtom(derivedAtom) + return
{result}
+ } + + const ForceValue = () => { + const setText = useAtom(textAtom)[1] + return ( +
+ +
+ ) + } + + const { getByText, findByText } = render( + + + + + ) + + await findByText('My very long data') + expect(syncAtomCount).toBe(1) + + fireEvent.click(getByText(`set value to 'hello'`)) + + await findByText('My very long data') + expect(syncAtomCount).toBe(1) +}) + +it('Should bail for derived async chains (#877)', async () => { + let syncAtomCount = 0 + const textAtom = atom('hello') + + const asyncAtom = atom(async (get) => { + get(textAtom) + syncAtomCount++ + return 'My very long data' + }) + + const derivedAtom = atom((get) => { + return get(asyncAtom) + }) + + const Input = () => { + const [result] = useAtom(derivedAtom) + return
{result}
+ } + + const ForceValue = () => { + const setText = useAtom(textAtom)[1] + return ( +
+ +
+ ) + } + + const { getByText, findByText } = render( + + + + + + + ) + + await findByText('My very long data') + expect(syncAtomCount).toBe(1) + + fireEvent.click(getByText(`set value to 'hello'`)) + + await findByText('My very long data') + expect(syncAtomCount).toBe(1) +}) + +it('update correctly with async updates (#1250)', async () => { + const countAtom = atom(0) + + const countIsGreaterThanOneAtom = atom((get) => get(countAtom) > 1) + + const alsoCountAtom = atom((get) => { + const count = get(countAtom) + get(countIsGreaterThanOneAtom) + return count + }) + + const App = () => { + const setCount = useSetAtom(countAtom) + const alsoCount = useAtomValue(alsoCountAtom) + const countIsGreaterThanOne = useAtomValue(countIsGreaterThanOneAtom) + const incrementCountTwice = () => { + setTimeout(() => setCount((count) => count + 1)) + setTimeout(() => setCount((count) => count + 1)) + } + return ( +
+ +
alsoCount: {alsoCount}
+
countIsGreaterThanOne: {countIsGreaterThanOne.toString()}
+
+ ) + } + + const { getByText } = render( + + + + ) + + await waitFor(() => { + getByText('alsoCount: 0') + getByText('countIsGreaterThanOne: false') + }) + + fireEvent.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) => { + const v0 = get(baseAtom) + const v1 = get(derived1Atom) + const v2 = get(derived2Atom) + return `v0: ${v0}, v1: ${v1}, v2: ${v2}` + }) + const derived3Atom = atom(computeValue) + + const App = () => { + const value = useAtomValue(derived3Atom) + return
value: {value}
+ } + + const Control = () => { + const setCount = useSetAtom(baseAtom) + return ( + <> + + + ) + } + + const { getByText, findByText } = render( + + + + + ) + + await findByText('value: v0: 0, v1: 0, v2: 0') + expect(computeValue).toBeCalledTimes(1) + + fireEvent.click(getByText('button')) + await findByText('value: v0: 1, v1: 1, v2: 1') + expect(computeValue).toBeCalledTimes(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) => { + const v0 = get(baseAtom) + const v1 = get(derived1Atom) + const v2 = get(derived2Atom) + return v0 + (v1 - v2) + }) + const derived3Atom = atom(computeValue) + + const App = () => { + const value = useAtomValue(derived3Atom) + return
value: {value}
+ } + + const Control = () => { + const setCount = useSetAtom(baseAtom) + return ( + <> + + + ) + } + + const { getByText, findByText } = render( + + + + + ) + + await findByText('value: 0') + expect(computeValue).toBeCalledTimes(1) + + fireEvent.click(getByText('button')) + await findByText('value: 1') + expect(computeValue).toBeCalledTimes(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) => { + const v0 = get(baseAtom) + const v1 = get(derived1Atom) + const v2 = get(derived2Atom) + const v3 = get(derived3Atom) + return v0 + (v1 - v2) + v3 * 0 + }) + const derived4Atom = atom(computeValue) + + const App = () => { + const value = useAtomValue(derived4Atom) + return
value: {value}
+ } + + const Control = () => { + const setCount = useSetAtom(baseAtom) + return ( + <> + + + ) + } + + const { getByText, findByText } = render( + + + + + ) + + await findByText('value: 0') + expect(computeValue).toBeCalledTimes(1) + + fireEvent.click(getByText('button')) + await findByText('value: 1') + expect(computeValue).toBeCalledTimes(2) + }) +}) diff --git a/tests/react/devtools/useAtomDevtools.test.tsx b/tests/react/devtools/useAtomDevtools.test.tsx new file mode 100644 index 0000000000..e03d895a1f --- /dev/null +++ b/tests/react/devtools/useAtomDevtools.test.tsx @@ -0,0 +1,334 @@ +import { StrictMode, Suspense } from 'react' +import { act, fireEvent, render } from '@testing-library/react' +import { useAtom } from 'jotai/react' +import { useAtomDevtools } from 'jotai/react/devtools' +import { atom } from 'jotai/vanilla' + +let extensionSubscriber: ((message: any) => void) | undefined + +const extension = { + subscribe: jest.fn((f) => { + extensionSubscriber = f + return () => {} + }), + unsubscribe: jest.fn(), + send: jest.fn(), + init: jest.fn(), + error: jest.fn(), +} +const extensionConnector = { connect: jest.fn(() => extension) } +;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = extensionConnector + +beforeEach(() => { + extensionConnector.connect.mockClear() + extension.subscribe.mockClear() + extension.unsubscribe.mockClear() + extension.send.mockClear() + extension.init.mockClear() + extension.error.mockClear() + extensionSubscriber = undefined +}) + +it('[DEV-ONLY] connects to the extension by initializing', () => { + __DEV__ = true + const countAtom = atom(0) + + const Counter = () => { + useAtomDevtools(countAtom) + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + render( + + + + ) + + expect(extension.init).toHaveBeenLastCalledWith(0) +}) + +describe('If there is no extension installed...', () => { + let savedDEV: boolean + beforeEach(() => { + savedDEV = __DEV__ + ;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = undefined + }) + afterAll(() => { + __DEV__ = savedDEV + ;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = extensionConnector + }) + + const countAtom = atom(0) + + const Counter = ({ enabled }: { enabled?: boolean }) => { + useAtomDevtools(countAtom, enabled ? { enabled } : undefined) + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + it('does not throw', () => { + __DEV__ = false + expect(() => { + render( + + + + ) + }).not.toThrow() + }) + + it('[DEV-ONLY] warns in dev env only if enabled', () => { + __DEV__ = true + const originalConsoleWarn = console.warn + console.warn = jest.fn() + + render( + <> + + + ) + expect(console.warn).toHaveBeenLastCalledWith( + 'Please install/enable Redux devtools extension' + ) + + console.warn = originalConsoleWarn + }) + + it('[PRD-ONLY] does not warn in prod env even if enabled is true', () => { + __DEV__ = false + const originalConsoleWarn = console.warn + console.warn = jest.fn() + + render( + + + + ) + + expect(console.warn).not.toHaveBeenLastCalledWith( + 'Please install/enable Redux devtools extension' + ) + + console.warn = originalConsoleWarn + }) + + it('[PRD-ONLY] does not warn if not in dev env', () => { + __DEV__ = false + console.error = jest.fn() + const consoleWarn = jest.spyOn(console, 'warn') + + render( + + + + ) + expect(consoleWarn).not.toBeCalled() + + consoleWarn.mockRestore() + }) +}) + +it('[DEV-ONLY] updating state should call devtools.send', async () => { + __DEV__ = true + const countAtom = atom(0) + + const Counter = () => { + useAtomDevtools(countAtom) + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + ) + + expect(extension.send).toBeCalledTimes(0) + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(extension.send).toBeCalledTimes(1) + fireEvent.click(getByText('button')) + await findByText('count: 2') + expect(extension.send).toBeCalledTimes(2) +}) + +describe('when it receives an message of type...', () => { + it('[DEV-ONLY] updating state with ACTION', async () => { + __DEV__ = true + const countAtom = atom(0) + + const Counter = () => { + useAtomDevtools(countAtom) + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + + + ) + + expect(extension.send).toBeCalledTimes(0) + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(extension.send).toBeCalledTimes(1) + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'ACTION', + payload: JSON.stringify(0), + }) + ) + await findByText('count: 0') + expect(extension.send).toBeCalledTimes(2) + }) + + describe('DISPATCH and payload of type...', () => { + it('[DEV-ONLY] dispatch & COMMIT', async () => { + __DEV__ = true + const countAtom = atom(0) + + const Counter = () => { + useAtomDevtools(countAtom) + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + ) + + expect(extension.send).toBeCalledTimes(0) + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(extension.send).toBeCalledTimes(1) + fireEvent.click(getByText('button')) + await findByText('count: 2') + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'COMMIT' }, + }) + ) + await findByText('count: 2') + expect(extension.init).toBeCalledWith(2) + }) + + it('[DEV-ONLY] dispatch & IMPORT_STATE', async () => { + __DEV__ = true + const countAtom = atom(0) + + const Counter = () => { + useAtomDevtools(countAtom) + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + ) + + const nextLiftedState = { + computedStates: [{ state: 5 }, { state: 6 }], + } + expect(extension.send).toBeCalledTimes(0) + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(extension.send).toBeCalledTimes(1) + fireEvent.click(getByText('button')) + await findByText('count: 2') + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'IMPORT_STATE', nextLiftedState }, + }) + ) + expect(extension.init).toBeCalledWith(5) + await findByText('count: 6') + }) + + describe('JUMP_TO_STATE | JUMP_TO_ACTION...', () => { + it('[DEV-ONLY] time travelling', async () => { + __DEV__ = true + const countAtom = atom(0) + + const Counter = () => { + useAtomDevtools(countAtom) + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + ) + + expect(extension.send).toBeCalledTimes(0) + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(extension.send).toBeCalledTimes(1) + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_ACTION' }, + state: JSON.stringify(0), + }) + ) + await findByText('count: 0') + expect(extension.send).toBeCalledTimes(1) + fireEvent.click(getByText('button')) + await findByText('count: 1') + fireEvent.click(getByText('button')) + await findByText('count: 2') + expect(extension.send).toBeCalledTimes(3) + }) + }) + }) +}) diff --git a/tests/react/devtools/useAtomsDevtools.test.tsx b/tests/react/devtools/useAtomsDevtools.test.tsx new file mode 100644 index 0000000000..f6d687932a --- /dev/null +++ b/tests/react/devtools/useAtomsDevtools.test.tsx @@ -0,0 +1,814 @@ +import { StrictMode, Suspense } from 'react' +import type { ReactElement } from 'react' +import { act, fireEvent, render, waitFor } from '@testing-library/react' +import { useAtom } from 'jotai/react' +import { useAtomsDevtools } from 'jotai/react/devtools' +import { atom } from 'jotai/vanilla' + +let extensionSubscriber: ((message: any) => void) | undefined + +const extension = { + subscribe: jest.fn((f) => { + extensionSubscriber = f + return () => {} + }), + unsubscribe: jest.fn(), + send: jest.fn(), + init: jest.fn(), + error: jest.fn(), +} +const disconnect = () => { + extensionConnector.connect.mockClear() + extension.subscribe.mockClear() + extension.unsubscribe.mockClear() + extension.send.mockClear() + extension.init.mockClear() + extension.error.mockClear() + extensionSubscriber = undefined +} + +const extensionConnector = { + connect: jest.fn(() => extension), + disconnect: jest.fn(disconnect), +} +;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = extensionConnector + +const savedDev = __DEV__ + +beforeEach(disconnect) + +afterEach(() => { + __DEV__ = savedDev +}) + +const AtomsDevtools = ({ + children, + enabled, +}: { + children: ReactElement + enabled?: boolean +}) => { + useAtomsDevtools('test', enabled ? { enabled } : undefined) + return children +} + +it('[DEV-ONLY] connects to the extension by initialiing', () => { + __DEV__ = true + const countAtom = atom(0) + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + render( + + + + + + ) + + expect(extension.init).toHaveBeenLastCalledWith(undefined) +}) + +describe('If there is no extension installed...', () => { + beforeEach(() => { + ;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = undefined + }) + afterEach(() => { + ;(window as any).__REDUX_DEVTOOLS_EXTENSION__ = extensionConnector + }) + + const countAtom = atom(0) + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + it('[DEV-ONLY] does not throw', () => { + __DEV__ = true + const originalConsoleWarn = console.warn + console.warn = jest.fn() + + expect(() => { + render( + + + + + + ) + }).not.toThrow() + + console.warn = originalConsoleWarn + }) + + it('[DEV-ONLY] warns in dev env only if enabled', () => { + __DEV__ = true + const originalConsoleWarn = console.warn + console.warn = jest.fn() + + render( + + + + + + ) + + expect(console.warn).toHaveBeenLastCalledWith( + 'Please install/enable Redux devtools extension' + ) + + console.warn = originalConsoleWarn + }) + + it('[PRD-ONLY] does not warn in prod env even if enabled is true', () => { + __DEV__ = false + const originalConsoleWarn = console.warn + console.warn = jest.fn() + + render( + + + + + + ) + + expect(console.warn).not.toHaveBeenLastCalledWith( + 'Please install/enable Redux devtools extension' + ) + + console.warn = originalConsoleWarn + }) +}) + +it('[DEV-ONLY] updating state should call devtools.send', async () => { + __DEV__ = true + const countAtom = atom(0) + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + + + ) + + await findByText('count: 0') + expect(extension.send).toBeCalledTimes(1) + + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(extension.send).toBeCalledTimes(2) + + fireEvent.click(getByText('button')) + await findByText('count: 2') + expect(extension.send).toBeCalledTimes(3) +}) + +it('[DEV-ONLY] updating state should call devtools.send once in StrictMode', async () => { + __DEV__ = true + const countAtom = atom(0) + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + extension.init.mockClear() + render( + + + + + + ) + + expect(extensionConnector.disconnect).toBeCalled() + expect(extension.init).toBeCalledTimes(1) +}) + +it('[DEV-ONLY] dependencies + updating state should call devtools.send', async () => { + __DEV__ = true + const countAtom = atom(0) + const doubleAtom = atom((get) => get(countAtom) * 2) + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const [double] = useAtom(doubleAtom) + return ( + <> +
count: {count}
+
double: {double}
+ + + ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + + + ) + + await findByText('count: 0') + expect(extension.send).toBeCalledTimes(1) + expect(extension.send).lastCalledWith( + expect.objectContaining({ type: '1' }), + expect.objectContaining({ + values: expect.objectContaining({ + [`${countAtom}`]: 0, + [`${doubleAtom}`]: 0, + }), + dependents: expect.objectContaining({ + [`${countAtom}`]: expect.arrayContaining([ + `${countAtom}`, + `${doubleAtom}`, + ]), + [`${doubleAtom}`]: [], + }), + }) + ) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 1') + getByText('double: 2') + }) + expect(extension.send).toBeCalledTimes(2) + expect(extension.send).lastCalledWith( + expect.objectContaining({ type: '2' }), + expect.objectContaining({ + values: expect.objectContaining({ + [`${countAtom}`]: 1, + [`${doubleAtom}`]: 2, + }), + dependents: expect.objectContaining({ + [`${countAtom}`]: expect.arrayContaining([ + `${countAtom}`, + `${doubleAtom}`, + ]), + [`${doubleAtom}`]: [], + }), + }) + ) + + fireEvent.click(getByText('button')) + await waitFor(() => { + getByText('count: 2') + getByText('double: 4') + }) + expect(extension.send).toBeCalledTimes(3) + expect(extension.send).lastCalledWith( + expect.objectContaining({ type: '3' }), + expect.objectContaining({ + values: expect.objectContaining({ + [`${countAtom}`]: 2, + [`${doubleAtom}`]: 4, + }), + dependents: expect.objectContaining({ + [`${countAtom}`]: expect.arrayContaining([ + `${countAtom}`, + `${doubleAtom}`, + ]), + [`${doubleAtom}`]: [], + }), + }) + ) +}) + +it('[DEV-ONLY] conditional dependencies + updating state should call devtools.send', async () => { + __DEV__ = true + const countAtom = atom(0) + const secondCountAtom = atom(0) + const enabledAtom = atom(true) + const anAtom = atom((get) => + get(enabledAtom) ? get(countAtom) : get(secondCountAtom) + ) + const App = () => { + const [enabled, setEnabled] = useAtom(enabledAtom) + const [cond] = useAtom(anAtom) + return ( +
+

enabled: {enabled ? 'true' : 'false'}

+

condition: {cond}

+ +
+ ) + } + + extension.send.mockClear() + const { getByText } = render( + + + + + + ) + + await waitFor(() => { + getByText('enabled: true') + getByText('condition: 0') + }) + expect(extension.send).toBeCalledTimes(1) + expect(extension.send).lastCalledWith( + expect.objectContaining({ type: '1' }), + expect.objectContaining({ + values: expect.objectContaining({ + [`${enabledAtom}`]: true, + [`${countAtom}`]: 0, + [`${anAtom}`]: 0, + }), + dependents: expect.objectContaining({ + [`${enabledAtom}`]: expect.arrayContaining([ + `${enabledAtom}`, + `${anAtom}`, + ]), + [`${countAtom}`]: expect.arrayContaining([`${countAtom}`, `${anAtom}`]), + [`${anAtom}`]: [], + }), + }) + ) + + fireEvent.click(getByText('change')) + await waitFor(() => { + getByText('enabled: false') + getByText('condition: 0') + }) + expect(extension.send).toBeCalledTimes(2) + expect(extension.send).lastCalledWith( + expect.objectContaining({ type: '2' }), + expect.objectContaining({ + values: expect.objectContaining({ + [`${enabledAtom}`]: false, + [`${secondCountAtom}`]: 0, + [`${anAtom}`]: 0, + }), + dependents: expect.objectContaining({ + [`${enabledAtom}`]: expect.arrayContaining([ + `${enabledAtom}`, + `${anAtom}`, + ]), + [`${secondCountAtom}`]: expect.arrayContaining([ + `${secondCountAtom}`, + `${anAtom}`, + ]), + [`${anAtom}`]: [], + }), + }) + ) + + fireEvent.click(getByText('change')) + await waitFor(() => { + getByText('enabled: true') + getByText('condition: 0') + }) + expect(extension.send).toBeCalledTimes(3) + expect(extension.send).lastCalledWith( + expect.objectContaining({ type: '3' }), + expect.objectContaining({ + values: expect.objectContaining({ + [`${enabledAtom}`]: true, + [`${countAtom}`]: 0, + [`${anAtom}`]: 0, + }), + dependents: expect.objectContaining({ + [`${enabledAtom}`]: expect.arrayContaining([ + `${enabledAtom}`, + `${anAtom}`, + ]), + [`${countAtom}`]: expect.arrayContaining([`${countAtom}`, `${anAtom}`]), + [`${anAtom}`]: [], + }), + }) + ) +}) + +it('[DEV-ONLY] with atoms invalidated after mount', async () => { + __DEV__ = true + const countAtom = atom(1) + const doubleCountAtom = atom((get) => get(countAtom) * 2) + let resolve = () => {} + const derivedAtom = atom((get) => { + const count = get(countAtom) + if (count % 2 === 0) { + return new Promise((r) => (resolve = () => r(count - 1))) + } + return count + }) + const Component = () => { + const [derived] = useAtom(derivedAtom) + const [doubleCount] = useAtom(doubleCountAtom) + return ( +
+
derived: {derived}
+
doubleCount: {doubleCount}
+
+ ) + } + const App = () => { + const [count, setCount] = useAtom(countAtom) + return ( +
+

count: {count}

+ + + + +
+ ) + } + + extension.send.mockClear() + const { getByText } = render( + + + + + + ) + + await waitFor(() => { + getByText('count: 1') + getByText('derived: 1') + getByText('doubleCount: 2') + }) + expect(extension.send).toBeCalledTimes(1) + expect(extension.send).lastCalledWith( + expect.objectContaining({ type: '1' }), + expect.objectContaining({ + values: expect.objectContaining({ + [`${countAtom}`]: 1, + [`${derivedAtom}`]: 1, + [`${doubleCountAtom}`]: 2, + }), + dependents: expect.objectContaining({ + [`${countAtom}`]: expect.arrayContaining([ + `${countAtom}`, + `${derivedAtom}`, + `${doubleCountAtom}`, + ]), + [`${doubleCountAtom}`]: [], + [`${derivedAtom}`]: [], + }), + }) + ) + + fireEvent.click(getByText('change')) + await waitFor(() => { + getByText('count: 2') + getByText('loading') + }) + await waitFor(() => { + expect(extension.send).toBeCalledTimes(2) + }) + expect(extension.send).lastCalledWith( + expect.objectContaining({ type: '2' }), + expect.objectContaining({ + values: expect.objectContaining({ + [`${countAtom}`]: 2, + }), + dependents: expect.objectContaining({ + [`${countAtom}`]: expect.arrayContaining([ + `${countAtom}`, + `${derivedAtom}`, + `${doubleCountAtom}`, + ]), + [`${derivedAtom}`]: [], + }), + }) + ) + + fireEvent.click(getByText('change')) + resolve() + await waitFor(() => { + getByText('count: 3') + getByText('derived: 3') + getByText('doubleCount: 6') + }) + expect(extension.send).toBeCalledTimes(3) + expect(extension.send).lastCalledWith( + expect.objectContaining({ type: '3' }), + expect.objectContaining({ + values: expect.objectContaining({ + [`${countAtom}`]: 3, + [`${derivedAtom}`]: 3, + [`${doubleCountAtom}`]: 6, + }), + dependents: expect.objectContaining({ + [`${countAtom}`]: expect.arrayContaining([ + `${countAtom}`, + `${derivedAtom}`, + `${doubleCountAtom}`, + ]), + [`${doubleCountAtom}`]: [], + [`${derivedAtom}`]: [], + }), + }) + ) +}) + +describe('when it receives an message of type...', () => { + it('[DEV-ONLY] dispatch & COMMIT', async () => { + __DEV__ = true + const countAtom = atom(0) + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + + + ) + + await findByText('count: 0') + expect(extension.send).toBeCalledTimes(1) + + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(extension.send).toBeCalledTimes(2) + + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'COMMIT' }, + }) + ) + await waitFor(() => + expect(extension.init).toBeCalledWith({ + values: { + [`${countAtom}`]: 1, + }, + dependents: { + [`${countAtom}`]: [`${countAtom}`], + }, + }) + ) + }) + + it('[DEV-ONLY] JUMP_TO_STATE & JUMP_TO_ACTION should not call devtools.send', async () => { + __DEV__ = true + const countAtom = atom(0) + const secondCountAtom = atom(0) + const enabledAtom = atom(true) + const anAtom = atom((get) => + get(enabledAtom) ? get(countAtom) : get(secondCountAtom) + ) + const App = () => { + const [enabled, setEnabled] = useAtom(enabledAtom) + const [cond] = useAtom(anAtom) + return ( +
+

enabled: {enabled ? 'true' : 'false'}

+

condition: {cond}

+ +
+ ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + + + ) + + await findByText('enabled: true') + + fireEvent.click(getByText('change')) + await findByText('enabled: false') + + fireEvent.click(getByText('change')) + await findByText('enabled: true') + + fireEvent.click(getByText('change')) + await waitFor(() => { + getByText('enabled: false') + getByText('condition: 0') + }) + expect(extension.send).toBeCalledTimes(4) + + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_STATE', actionId: 3 }, + }) + ) + await waitFor(() => { + getByText('enabled: true') + getByText('condition: 0') + }) + expect(extension.send).toBeCalledTimes(4) + + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_STATE', actionId: 2 }, + }) + ) + await waitFor(() => { + getByText('enabled: false') + getByText('condition: 0') + }) + expect(extension.send).toBeCalledTimes(4) + }) + + it('[DEV-ONLY] time travelling with JUMP_TO_ACTION', async () => { + __DEV__ = true + const countAtom = atom(0) + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + + + ) + + await findByText('count: 0') + expect(extension.send).toBeCalledTimes(1) + + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(extension.send).toBeCalledTimes(2) + + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_ACTION', actionId: 1 }, + }) + ) + await findByText('count: 0') + expect(extension.send).toBeCalledTimes(2) + + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(extension.send).toBeCalledTimes(3) + + fireEvent.click(getByText('button')) + await findByText('count: 2') + expect(extension.send).toBeCalledTimes(4) + }) + + it('[DEV-ONLY] time travelling with JUMP_TO_STATE', async () => { + __DEV__ = true + const countAtom = atom(0) + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + + + ) + + await findByText('count: 0') + expect(extension.send).toBeCalledTimes(1) + + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(extension.send).toBeCalledTimes(2) + + fireEvent.click(getByText('button')) + await findByText('count: 2') + expect(extension.send).toBeCalledTimes(3) + + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_STATE', actionId: 2 }, + }) + ) + await findByText('count: 1') + + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_STATE', actionId: 1 }, + }) + ) + await findByText('count: 0') + + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_STATE', actionId: 0 }, + }) + ) + await findByText('count: 0') + + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'JUMP_TO_STATE', actionId: 3 }, + }) + ) + await findByText('count: 2') + }) + + it('[DEV-ONLY] PAUSE_RECORDING, it toggles the sending of actions', async () => { + __DEV__ = true + const countAtom = atom(0) + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + extension.send.mockClear() + const { getByText, findByText } = render( + + + + + + ) + + await findByText('count: 0') + expect(extension.send).toBeCalledTimes(1) + + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'PAUSE_RECORDING' }, + }) + ) + fireEvent.click(getByText('button')) + await findByText('count: 1') + expect(extension.send).toBeCalledTimes(1) + + act(() => + (extensionSubscriber as (message: any) => void)({ + type: 'DISPATCH', + payload: { type: 'PAUSE_RECORDING' }, + }) + ) + fireEvent.click(getByText('button')) + await findByText('count: 2') + expect(extension.send).toBeCalledTimes(2) + }) +}) diff --git a/tests/react/devtools/useAtomsSnapshot.test.tsx b/tests/react/devtools/useAtomsSnapshot.test.tsx new file mode 100644 index 0000000000..1e055b29e7 --- /dev/null +++ b/tests/react/devtools/useAtomsSnapshot.test.tsx @@ -0,0 +1,206 @@ +import { StrictMode, useState } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { Provider, useAtom } from 'jotai/react' +import { useAtomsSnapshot } from 'jotai/react/devtools' +import { atom, createStore } from 'jotai/vanilla' + +it('[DEV-ONLY] should register newly added atoms', async () => { + __DEV__ = true + const countAtom = atom(1) + const petAtom = atom('cat') + + const DisplayCount = () => { + const [clicked, setClicked] = useState(false) + const [count] = useAtom(countAtom) + + return ( + <> +

count: {count}

+ + {clicked && } + + ) + } + + const DisplayPet = () => { + const [pet] = useAtom(petAtom) + return

pet: {pet}

+ } + + const RegisteredAtomsCount = () => { + const atoms = useAtomsSnapshot().values + + return

atom count: {atoms.size}

+ } + + const { findByText, getByText } = render( + + + + + ) + + await findByText('atom count: 1') + fireEvent.click(getByText('click')) + await findByText('atom count: 2') +}) + +it('[DEV-ONLY] should let you access atoms and their state', async () => { + __DEV__ = true + const countAtom = atom(1) + countAtom.debugLabel = 'countAtom' + const petAtom = atom('cat') + petAtom.debugLabel = 'petAtom' + + const Displayer = () => { + useAtom(countAtom) + useAtom(petAtom) + return null + } + + const SimpleDevtools = () => { + const atoms = useAtomsSnapshot().values + + return ( +
+ {Array.from(atoms).map(([atom, atomValue]) => ( +

{`${atom.debugLabel}: ${atomValue}`}

+ ))} +
+ ) + } + + const { findByText } = render( + + + + + ) + + await findByText('countAtom: 1') + await findByText('petAtom: cat') +}) + +it('[DEV-ONLY] should contain initial values', async () => { + __DEV__ = true + const countAtom = atom(1) + countAtom.debugLabel = 'countAtom' + const petAtom = atom('cat') + petAtom.debugLabel = 'petAtom' + + const Displayer = () => { + useAtom(countAtom) + useAtom(petAtom) + return null + } + + const SimpleDevtools = () => { + const atoms = useAtomsSnapshot().values + + return ( +
+ {Array.from(atoms).map(([atom, atomValue]) => ( +

{`${atom.debugLabel}: ${atomValue}`}

+ ))} +
+ ) + } + + const store = createStore() + store.set(countAtom, 42) + store.set(petAtom, 'dog') + + const { findByText } = render( + + + + + + + ) + + await findByText('countAtom: 42') + await findByText('petAtom: dog') +}) + +it('[DEV-ONLY] conditional dependencies + updating state should call devtools.send', async () => { + __DEV__ = true + const countAtom = atom(0) + countAtom.debugLabel = 'countAtom' + const secondCountAtom = atom(0) + secondCountAtom.debugLabel = 'secondCountAtom' + const enabledAtom = atom(true) + enabledAtom.debugLabel = 'enabledAtom' + const anAtom = atom((get) => + get(enabledAtom) ? get(countAtom) : get(secondCountAtom) + ) + anAtom.debugLabel = 'anAtom' + const App = () => { + const [enabled, setEnabled] = useAtom(enabledAtom) + const [cond] = useAtom(anAtom) + + return ( +
+

enabled: {enabled ? 'true' : 'false'}

+

condition: {cond}

+ +
+ ) + } + + const SimpleDevtools = () => { + const { dependents } = useAtomsSnapshot() + + const obj: Record = {} + + for (const [atom, dependentAtoms] of dependents) { + obj[`${atom}`] = [...dependentAtoms].map((_atom) => `${_atom}`) + } + + return
{JSON.stringify(obj)}
+ } + + const { getByText } = render( + + + + + ) + + await waitFor(() => { + getByText('enabled: true') + getByText('condition: 0') + getByText( + JSON.stringify({ + [`${enabledAtom}`]: [`${enabledAtom}`, `${anAtom}`], + [`${anAtom}`]: [], + [`${countAtom}`]: [`${anAtom}`, `${countAtom}`], + }) + ) + }) + fireEvent.click(getByText('change')) + await waitFor(() => { + getByText('enabled: false') + getByText('condition: 0') + getByText( + JSON.stringify({ + [`${enabledAtom}`]: [`${enabledAtom}`, `${anAtom}`], + [`${anAtom}`]: [], + [`${secondCountAtom}`]: [`${anAtom}`, `${secondCountAtom}`], + }) + ) + }) + + fireEvent.click(getByText('change')) + await waitFor(() => { + getByText('enabled: true') + getByText('condition: 0') + getByText( + JSON.stringify({ + [`${enabledAtom}`]: [`${enabledAtom}`, `${anAtom}`], + [`${anAtom}`]: [], + [`${countAtom}`]: [`${anAtom}`, `${countAtom}`], + }) + ) + }) +}) diff --git a/tests/react/devtools/useGoToAtomsSnapshot.test.tsx b/tests/react/devtools/useGoToAtomsSnapshot.test.tsx new file mode 100644 index 0000000000..04012cc14d --- /dev/null +++ b/tests/react/devtools/useGoToAtomsSnapshot.test.tsx @@ -0,0 +1,234 @@ +import { StrictMode, Suspense, useEffect, useRef } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useAtom } from 'jotai/react' +import { useAtomsSnapshot, useGotoAtomsSnapshot } from 'jotai/react/devtools' +import { atom } from 'jotai/vanilla' +import type { Atom } from 'jotai/vanilla' + +it('[DEV-ONLY] useGotoAtomsSnapshot should modify atoms snapshot', async () => { + __DEV__ = true + const petAtom = atom('cat') + const colorAtom = atom('blue') + + const DisplayAtoms = () => { + const [pet] = useAtom(petAtom) + const [color] = useAtom(colorAtom) + return ( + <> +

{pet}

+

{color}

+ + ) + } + + const UpdateSnapshot = () => { + const snapshot = useAtomsSnapshot() + const goToSnapshot = useGotoAtomsSnapshot() + return ( + + ) + } + + const { findByText, getByText } = render( + + + + + ) + + await findByText('cat') + await findByText('blue') + + fireEvent.click(getByText('click')) + await findByText('dog') + await findByText('green') +}) + +it('[DEV-ONLY] useGotoAtomsSnapshot should work with derived atoms', async () => { + __DEV__ = true + const priceAtom = atom(10) + const taxAtom = atom((get) => get(priceAtom) * 0.2) + + const DisplayPrice = () => { + const [price] = useAtom(priceAtom) + const [tax] = useAtom(taxAtom) + return ( + <> +

price: {price}

+

tax: {tax}

+ + ) + } + + const UpdateSnapshot = () => { + const snapshot = useAtomsSnapshot() + const goToSnapshot = useGotoAtomsSnapshot() + return ( + + ) + } + + const { getByText } = render( + + + + + ) + + await waitFor(() => { + getByText('price: 10') + getByText('tax: 2') + }) + + fireEvent.click(getByText('click')) + await waitFor(() => { + getByText('price: 20') + getByText('tax: 4') + }) +}) + +it('[DEV-ONLY] useGotoAtomsSnapshot should work with async derived atoms', async () => { + __DEV__ = true + const priceAtom = atom(10) + let resolve = () => {} + const taxAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)) + return get(priceAtom) * 0.2 + }) + + const DisplayPrice = () => { + const [price] = useAtom(priceAtom) + const [tax] = useAtom(taxAtom) + return ( + <> +

price: {price}

+

tax: {tax}

+ + ) + } + + const UpdateSnapshot = () => { + const snapshot = useAtomsSnapshot() + const goToSnapshot = useGotoAtomsSnapshot() + return ( + + ) + } + + const { findByText, getByText } = render( + + + + + + + ) + + await findByText('loading') + resolve() + await waitFor(() => { + getByText('price: 10') + getByText('tax: 2') + }) + + fireEvent.click(getByText('click')) + await findByText('loading') + resolve() + await waitFor(() => { + getByText('price: 20') + getByText('tax: 4') + }) +}) + +it('[DEV-ONLY] useGotoAtomsSnapshot should work with original snapshot', async () => { + __DEV__ = true + const priceAtom = atom(10) + const taxAtom = atom((get) => get(priceAtom) * 0.2) + + const DisplayPrice = () => { + const [price, setPrice] = useAtom(priceAtom) + const [tax] = useAtom(taxAtom) + return ( + <> +

price: {price}

+

tax: {tax}

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

count: {count}

+

pet: {pet}

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

count: {count}

+

pet: {pet}

+ + ) + } + + const store = createStore() + store.set(petAtom, 'dog') + + const { getByText } = render( + + + + + + ) + + await waitFor(() => { + getByText('count: 1') + getByText('pet: dog') + }) +}) + +it('renders correctly without children', () => { + render( + + + + ) +}) diff --git a/tests/react/transition.test.tsx b/tests/react/transition.test.tsx new file mode 100644 index 0000000000..9c6ff018d8 --- /dev/null +++ b/tests/react/transition.test.tsx @@ -0,0 +1,107 @@ +import { StrictMode, Suspense, useEffect, useTransition } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useAtom, useAtomValue, useSetAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' + +const describeWithUseTransition = + typeof useTransition === 'function' ? describe : describe.skip + +describeWithUseTransition('useTransition', () => { + it('no extra commit with useTransition (#1125)', async () => { + const countAtom = atom(0) + let resolve = () => {} + const delayedAtom = atom(async (get) => { + await new Promise((r) => (resolve = r)) + return get(countAtom) + }) + + const commited: { pending: boolean; delayed: number }[] = [] + + const Counter = () => { + const setCount = useSetAtom(countAtom) + const delayed = useAtomValue(delayedAtom) + const [pending, startTransition] = useTransition() + useEffect(() => { + commited.push({ pending, delayed }) + }) + return ( + <> +
delayed: {delayed}
+ + + ) + } + + const { getByText, findByText } = render( + <> + + + + + ) + + resolve() + await findByText('delayed: 0') + + fireEvent.click(getByText('button')) + await waitFor(() => { + resolve() + getByText('delayed: 1') + }) + + expect(commited).toEqual([ + { pending: false, delayed: 0 }, + { pending: true, delayed: 0 }, + { pending: false, delayed: 1 }, + ]) + }) + + it('can update normal atom with useTransition (#1151)', async () => { + const countAtom = atom(0) + const toggleAtom = atom(false) + const pendingAtom = atom((get) => { + if (get(toggleAtom)) { + return new Promise(() => {}) + } + return false + }) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const toggle = useSetAtom(toggleAtom) + useAtomValue(pendingAtom) + const [pending, startTransition] = useTransition() + return ( + <> +
count: {count}
+ + {pending && 'pending'} + + + ) + } + + const { getByText, findByText } = render( + + + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('toggle')) + await findByText('pending') + + fireEvent.click(getByText('increment')) + await findByText('count: 1') + + fireEvent.click(getByText('increment')) + await findByText('count: 2') + }) +}) diff --git a/tests/react/types.test.tsx b/tests/react/types.test.tsx new file mode 100644 index 0000000000..42eab9e801 --- /dev/null +++ b/tests/react/types.test.tsx @@ -0,0 +1,31 @@ +import { expectType } from 'ts-expect' +import { useAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' + +it('useAtom should return the correct types', () => { + function Component() { + // primitive atom + const primitiveAtom = atom(0) + expectType<[number, (arg: number) => void]>(useAtom(primitiveAtom)) + + // read-only derived atom + const readonlyDerivedAtom = atom((get) => get(primitiveAtom) * 2) + expectType<[number, (arg: number) => void]>(useAtom(readonlyDerivedAtom)) + + // read-write derived atom + const readWriteDerivedAtom = atom( + (get) => get(primitiveAtom), + (get, set, value: number) => { + set(primitiveAtom, get(primitiveAtom) + value) + } + ) + expectType<[number, (arg: number) => void]>(useAtom(readWriteDerivedAtom)) + + // write-only derived atom + const writeonlyDerivedAtom = atom(null, (get, set) => { + set(primitiveAtom, get(primitiveAtom) - 1) + }) + expectType<[null, (arg: number) => void]>(useAtom(writeonlyDerivedAtom)) + } + Component +}) diff --git a/tests/react/useAtomValue.test.tsx b/tests/react/useAtomValue.test.tsx new file mode 100644 index 0000000000..9ed2b1eaa2 --- /dev/null +++ b/tests/react/useAtomValue.test.tsx @@ -0,0 +1,29 @@ +import { StrictMode } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { useAtomValue, useSetAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' + +it('useAtomValue basic test', async () => { + const countAtom = atom(0) + + const Counter = () => { + const count = useAtomValue(countAtom) + const setCount = useSetAtom(countAtom) + + return ( + <> +
count: {count}
+ + + ) + } + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 0') + fireEvent.click(getByText('dispatch')) + await findByText('count: 1') +}) diff --git a/tests/react/useSetAtom.test.tsx b/tests/react/useSetAtom.test.tsx new file mode 100644 index 0000000000..c76468d3ca --- /dev/null +++ b/tests/react/useSetAtom.test.tsx @@ -0,0 +1,118 @@ +import { StrictMode, useEffect, useRef } from 'react' +import type { PropsWithChildren } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useAtomValue, useSetAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' + +const useCommitCount = () => { + const commitCountRef = useRef(1) + useEffect(() => { + commitCountRef.current += 1 + }) + return commitCountRef.current +} + +it('useSetAtom does not trigger rerender in component', async () => { + const countAtom = atom(0) + + const Displayer = () => { + const count = useAtomValue(countAtom) + const commits = useCommitCount() + return ( +
+ count: {count}, commits: {commits} +
+ ) + } + + const Updater = () => { + const setCount = useSetAtom(countAtom) + const commits = useCommitCount() + return ( + <> + +
updater commits: {commits}
+ + ) + } + + const Parent = () => { + return ( + <> + + + + ) + } + + const { getByText } = render( + <> + + + ) + + await waitFor(() => { + getByText('count: 0, commits: 1') + getByText('updater commits: 1') + }) + fireEvent.click(getByText('increment')) + await waitFor(() => { + getByText('count: 1, commits: 2') + getByText('updater commits: 1') + }) + fireEvent.click(getByText('increment')) + await waitFor(() => { + getByText('count: 2, commits: 3') + getByText('updater commits: 1') + }) + fireEvent.click(getByText('increment')) + await waitFor(() => { + getByText('count: 3, commits: 4') + getByText('updater commits: 1') + }) +}) + +it('useSetAtom with write without an argument', async () => { + const countAtom = atom(0) + const incrementCountAtom = atom(null, (get, set) => + set(countAtom, get(countAtom) + 1) + ) + + const Button = ({ cb, children }: PropsWithChildren<{ cb: () => void }>) => ( + + ) + + const Displayer = () => { + const count = useAtomValue(countAtom) + return
count: {count}
+ } + + const Updater = () => { + const setCount = useSetAtom(incrementCountAtom) + return + } + + const Parent = () => { + return ( + <> + + + + ) + } + const { getByText } = render( + + + + ) + + await waitFor(() => { + getByText('count: 0') + }) + fireEvent.click(getByText('increment')) + await waitFor(() => { + getByText('count: 1') + }) +}) diff --git a/tests/react/utils/useAtomCallback.test.tsx b/tests/react/utils/useAtomCallback.test.tsx new file mode 100644 index 0000000000..a12b2aacbc --- /dev/null +++ b/tests/react/utils/useAtomCallback.test.tsx @@ -0,0 +1,174 @@ +import { StrictMode, useCallback, useEffect, useState } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useAtom } from 'jotai/react' +import { useAtomCallback } from 'jotai/react/utils' +import { atom } from 'jotai/vanilla' + +it('useAtomCallback with get', async () => { + const countAtom = atom(0) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
atom count: {count}
+ + + ) + } + + const Monitor = () => { + const [count, setCount] = useState(0) + const readCount = useAtomCallback( + useCallback((get) => { + const currentCount = get(countAtom) + setCount(currentCount) + return currentCount + }, []) + ) + useEffect(() => { + const timer = setInterval(async () => { + await readCount() + }, 10) + return () => { + clearInterval(timer) + } + }, [readCount]) + return ( + <> +
state count: {count}
+ + ) + } + + const { findByText, getByText } = render( + + + + + ) + + await findByText('atom count: 0') + fireEvent.click(getByText('dispatch')) + await waitFor(() => { + getByText('atom count: 1') + getByText('state count: 1') + }) +}) + +it('useAtomCallback with set and update', async () => { + const countAtom = atom(0) + const changeableAtom = atom(0) + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const Monitor = () => { + const [changeableCount] = useAtom(changeableAtom) + const changeCount = useAtomCallback( + useCallback((get, set) => { + const currentCount = get(countAtom) + set(changeableAtom, currentCount) + return currentCount + }, []) + ) + useEffect(() => { + const timer = setInterval(async () => { + await changeCount() + }, 10) + return () => { + clearInterval(timer) + } + }, [changeCount]) + return ( + <> +
changeable count: {changeableCount}
+ + ) + } + + const { findByText, getByText } = render( + + + + + ) + + await findByText('count: 0') + fireEvent.click(getByText('dispatch')) + await waitFor(() => { + getByText('count: 1') + getByText('changeable count: 1') + }) +}) + +it('useAtomCallback with set and update and arg', async () => { + const countAtom = atom(0) + + const App = () => { + const [count] = useAtom(countAtom) + const setCount = useAtomCallback( + useCallback((_get, set, arg: number) => { + set(countAtom, arg) + return arg + }, []) + ) + + return ( +
+

count: {count}

+ +
+ ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 0') + fireEvent.click(getByText('dispatch')) + await waitFor(() => { + getByText('count: 42') + }) +}) + +it('useAtomCallback with sync atom (#1100)', async () => { + const countAtom = atom(0) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + const readCount = useAtomCallback(useCallback((get) => get(countAtom), [])) + useEffect(() => { + const promiseOrValue = readCount() + if (typeof promiseOrValue !== 'number') { + throw new Error('should return number') + } + }, [readCount]) + return ( + <> +
atom count: {count}
+ + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('atom count: 0') + + fireEvent.click(getByText('dispatch')) + await findByText('atom count: 1') +}) diff --git a/tests/react/utils/useHydrateAtoms.test.tsx b/tests/react/utils/useHydrateAtoms.test.tsx new file mode 100644 index 0000000000..f6de87988f --- /dev/null +++ b/tests/react/utils/useHydrateAtoms.test.tsx @@ -0,0 +1,212 @@ +import { StrictMode, useEffect, useRef } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { useAtom } 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 Counter = ({ initialCount }: { initialCount: number }) => { + useHydrateAtoms([[countAtom, initialCount]]) + const [countValue, setCount] = useAtom(countAtom) + + return ( + <> +
count: {countValue}
+ + + ) + } + const { findByText, getByText, rerender } = render( + + + + ) + + await findByText('count: 42') + fireEvent.click(getByText('dispatch')) + await findByText('count: 43') + + rerender( + + + + ) + await findByText('count: 43') +}) + +it('useHydrateAtoms should not trigger unnessesary rerenders', async () => { + const countAtom = atom(0) + + const Counter = ({ initialCount }: { initialCount: number }) => { + useHydrateAtoms([[countAtom, initialCount]]) + const [countValue, setCount] = useAtom(countAtom) + const commitCount = useRef(1) + useEffect(() => { + ++commitCount.current + }) + return ( + <> +
commits: {commitCount.current}
+
count: {countValue}
+ + + ) + } + + const { findByText, getByText } = render( + <> + + + ) + + await findByText('count: 42') + await findByText('commits: 1') + fireEvent.click(getByText('dispatch')) + await findByText('count: 43') + await findByText('commits: 2') +}) + +it('useHydrateAtoms should work with derived atoms', async () => { + const countAtom = atom(0) + const doubleAtom = atom((get) => get(countAtom) * 2) + + const Counter = ({ initialCount }: { initialCount: number }) => { + useHydrateAtoms([[countAtom, initialCount]]) + const [countValue, setCount] = useAtom(countAtom) + const [doubleCount] = useAtom(doubleAtom) + return ( + <> +
count: {countValue}
+
doubleCount: {doubleCount}
+ + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 42') + await findByText('doubleCount: 84') + fireEvent.click(getByText('dispatch')) + await findByText('count: 43') + await findByText('doubleCount: 86') +}) + +it('useHydrateAtoms can only restore an atom once', async () => { + const countAtom = atom(0) + + const Counter = ({ initialCount }: { initialCount: number }) => { + useHydrateAtoms([[countAtom, initialCount]]) + const [countValue, setCount] = useAtom(countAtom) + + return ( + <> +
count: {countValue}
+ + + ) + } + const Counter2 = ({ count }: { count: number }) => { + useHydrateAtoms([[countAtom, count]]) + const [countValue, setCount] = useAtom(countAtom) + + return ( + <> +
count: {countValue}
+ + + ) + } + const { findByText, getByText, rerender } = render( + + + + ) + + await findByText('count: 42') + fireEvent.click(getByText('dispatch')) + await findByText('count: 43') + + rerender( + + + + ) + + await findByText('count: 43') + fireEvent.click(getByText('dispatch')) + await findByText('count: 44') +}) + +it('useHydrateAtoms can only restore an atom once', async () => { + const countAtom = atom(0) + + const Counter = ({ initialCount }: { initialCount: number }) => { + useHydrateAtoms([[countAtom, initialCount]]) + const [countValue, setCount] = useAtom(countAtom) + + return ( + <> +
count: {countValue}
+ + + ) + } + const Counter2 = ({ count }: { count: number }) => { + useHydrateAtoms([[countAtom, count]]) + const [countValue, setCount] = useAtom(countAtom) + + return ( + <> +
count: {countValue}
+ + + ) + } + const { findByText, getByText, rerender } = render( + + + + ) + + await findByText('count: 42') + fireEvent.click(getByText('dispatch')) + await findByText('count: 43') + + rerender( + + + + ) + + await findByText('count: 43') + fireEvent.click(getByText('dispatch')) + await findByText('count: 44') +}) + +it('useHydrateAtoms should respect onMount', async () => { + const countAtom = atom(0) + const onMountFn = jest.fn() + countAtom.onMount = onMountFn + + const Counter = ({ initialCount }: { initialCount: number }) => { + useHydrateAtoms([[countAtom, initialCount]]) + const [countValue] = useAtom(countAtom) + + return
count: {countValue}
+ } + const { findByText } = render( + <> + + + ) + + await findByText('count: 42') + expect(onMountFn).toBeCalledTimes(1) +}) diff --git a/tests/react/utils/useReducerAtom.test.tsx b/tests/react/utils/useReducerAtom.test.tsx new file mode 100644 index 0000000000..a33f68e108 --- /dev/null +++ b/tests/react/utils/useReducerAtom.test.tsx @@ -0,0 +1,113 @@ +import { StrictMode } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { useReducerAtom } from 'jotai/react/utils' +import { atom } from 'jotai/vanilla' + +it('useReducerAtom with no action argument', async () => { + const countAtom = atom(0) + const reducer = (state: number) => state + 2 + + const Parent = () => { + const [count, dispatch] = useReducerAtom(countAtom, reducer) + return ( + <> +
count: {count}
+ + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('dispatch')) + await findByText('count: 2') + + fireEvent.click(getByText('dispatch')) + await findByText('count: 4') +}) + +it('useReducerAtom with optional action argument', async () => { + const countAtom = atom(0) + const reducer = (state: number, action?: 'INCREASE' | 'DECREASE') => { + switch (action) { + case 'INCREASE': + return state + 1 + case 'DECREASE': + return state - 1 + case undefined: + return state + } + } + + const Parent = () => { + const [count, dispatch] = useReducerAtom(countAtom, reducer) + return ( + <> +
count: {count}
+ + + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('dispatch INCREASE')) + await findByText('count: 1') + + fireEvent.click(getByText('dispatch empty')) + await findByText('count: 1') + + fireEvent.click(getByText('dispatch DECREASE')) + await findByText('count: 0') +}) + +it('useReducerAtom with non-optional action argument', async () => { + const countAtom = atom(0) + const reducer = (state: number, action: 'INCREASE' | 'DECREASE') => { + switch (action) { + case 'INCREASE': + return state + 1 + case 'DECREASE': + return state - 1 + } + } + + const Parent = () => { + const [count, dispatch] = useReducerAtom(countAtom, reducer) + return ( + <> +
count: {count}
+ + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('dispatch INCREASE')) + await findByText('count: 1') + + fireEvent.click(getByText('dispatch DECREASE')) + await findByText('count: 0') +}) diff --git a/tests/react/utils/useResetAtom.test.tsx b/tests/react/utils/useResetAtom.test.tsx new file mode 100644 index 0000000000..a0bb33c45a --- /dev/null +++ b/tests/react/utils/useResetAtom.test.tsx @@ -0,0 +1,167 @@ +import { StrictMode } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { useAtom } from 'jotai/react' +import { useResetAtom } from 'jotai/react/utils' +import { atom } from 'jotai/vanilla' +import { RESET, atomWithReducer, atomWithReset } from 'jotai/vanilla/utils' + +it('atomWithReset resets to its first value', async () => { + const countAtom = atomWithReset(0) + + const Parent = () => { + const [count, setValue] = useAtom(countAtom) + const resetAtom = useResetAtom(countAtom) + return ( + <> +
count: {count}
+ + + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('increment')) + await findByText('count: 1') + fireEvent.click(getByText('increment')) + await findByText('count: 2') + fireEvent.click(getByText('increment')) + await findByText('count: 3') + + fireEvent.click(getByText('reset')) + await findByText('count: 0') + + fireEvent.click(getByText('set to 10')) + await findByText('count: 10') + + fireEvent.click(getByText('increment')) + await findByText('count: 11') + fireEvent.click(getByText('increment')) + await findByText('count: 12') + fireEvent.click(getByText('increment')) + await findByText('count: 13') +}) + +it('atomWithReset reset based on previous value', async () => { + const countAtom = atomWithReset(0) + + const Parent = () => { + const [count, setValue] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('increment till 3, then reset')) + await findByText('count: 1') + fireEvent.click(getByText('increment till 3, then reset')) + await findByText('count: 2') + fireEvent.click(getByText('increment till 3, then reset')) + await findByText('count: 3') + + fireEvent.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) + ) + + const Parent = () => { + const [count, setValue] = useAtom(countAtom) + const resetAtom = useResetAtom(countAtom) + return ( + <> +
count: {count}
+ + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('set to 10')) + await findByText('count: 10') + + fireEvent.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 + } + } + + const countAtom = atomWithReducer(0, reducer) + + const Parent = () => { + const [count, dispatch] = useAtom(countAtom) + const resetAtom = useResetAtom(countAtom) + return ( + <> +
count: {count}
+ + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('increment')) + await findByText('count: 1') + fireEvent.click(getByText('increment')) + await findByText('count: 2') + fireEvent.click(getByText('increment')) + await findByText('count: 3') + + fireEvent.click(getByText('reset')) + await findByText('count: 0') +}) diff --git a/tests/vanilla/basic.test.tsx b/tests/vanilla/basic.test.tsx new file mode 100644 index 0000000000..5fe07f39cc --- /dev/null +++ b/tests/vanilla/basic.test.tsx @@ -0,0 +1,51 @@ +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], + }, + } + `) +}) diff --git a/tests/vanilla/dependency.test.tsx b/tests/vanilla/dependency.test.tsx new file mode 100644 index 0000000000..750182bb48 --- /dev/null +++ b/tests/vanilla/dependency.test.tsx @@ -0,0 +1,29 @@ +import { atom, createStore } from 'jotai/vanilla' + +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).toBe(true) + resolve() + await expect(store.get(async3Atom)).resolves.toBe(1) + + store.set(countAtom, (c) => c + 1) + expect(store.get(async3Atom) instanceof Promise).toBe(true) + resolve() + await expect(store.get(async3Atom)).resolves.toBe(2) + + store.set(countAtom, (c) => c + 1) + expect(store.get(async3Atom) instanceof Promise).toBe(true) + resolve() + await expect(store.get(async3Atom)).resolves.toBe(3) +}) diff --git a/tests/vanilla/types.test.tsx b/tests/vanilla/types.test.tsx new file mode 100644 index 0000000000..8f647650ed --- /dev/null +++ b/tests/vanilla/types.test.tsx @@ -0,0 +1,31 @@ +import { expectType } from 'ts-expect' +import { atom } from 'jotai/vanilla' +import type { Atom, PrimitiveAtom, WritableAtom } from 'jotai/vanilla' + +it('atom() should return the correct types', () => { + function Component() { + // primitive atom + const primitiveAtom = atom(0) + expectType>(primitiveAtom) + + // read-only derived atom + const readonlyDerivedAtom = atom((get) => get(primitiveAtom) * 2) + expectType>(readonlyDerivedAtom) + + // read-write derived atom + const readWriteDerivedAtom = atom( + (get) => get(primitiveAtom), + (get, set, value: number) => { + set(primitiveAtom, get(primitiveAtom) + value) + } + ) + expectType>(readWriteDerivedAtom) + + // write-only derived atom + const writeonlyDerivedAtom = atom(null, (get, set) => { + set(primitiveAtom, get(primitiveAtom) - 1) + }) + expectType>(writeonlyDerivedAtom) + } + Component +}) diff --git a/tests/vanilla/utils/atomFamily.test.tsx b/tests/vanilla/utils/atomFamily.test.tsx new file mode 100644 index 0000000000..adfb587cd1 --- /dev/null +++ b/tests/vanilla/utils/atomFamily.test.tsx @@ -0,0 +1,282 @@ +import { StrictMode, Suspense, useState } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useAtom, useSetAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' +import type { SetStateAction, WritableAtom } from 'jotai/vanilla' +import { atomFamily } from 'jotai/vanilla/utils' + +it('new atomFamily impl', async () => { + const myFamily = atomFamily((param: string) => atom(param)) + + const Displayer = ({ index }: { index: string }) => { + const [count] = useAtom(myFamily(index)) + return
count: {count}
+ } + const { findByText } = render( + + + + ) + + await findByText('count: a') +}) + +it('primitive atomFamily returns same reference for same parameters', async () => { + const myFamily = atomFamily((num: number) => atom({ num })) + expect(myFamily(0)).toEqual(myFamily(0)) + expect(myFamily(0)).not.toEqual(myFamily(1)) + expect(myFamily(1)).not.toEqual(myFamily(0)) +}) + +it('read-only derived atomFamily returns same reference for same parameters', async () => { + const arrayAtom = atom([0]) + const myFamily = atomFamily((num: number) => + atom((get) => get(arrayAtom)[num] as number) + ) + expect(myFamily(0)).toEqual(myFamily(0)) + expect(myFamily(0)).not.toEqual(myFamily(1)) + expect(myFamily(1)).not.toEqual(myFamily(0)) +}) + +it('removed atom creates a new reference', async () => { + const bigAtom = atom([0]) + const myFamily = atomFamily((num: number) => + atom((get) => get(bigAtom)[num] as number) + ) + + const savedReference = myFamily(0) + + expect(savedReference).toEqual(myFamily(0)) + + myFamily.remove(0) + + const newReference = myFamily(0) + + expect(savedReference).not.toEqual(newReference) + + myFamily.remove(1337) + + expect(myFamily(0)).toEqual(newReference) +}) + +it('primitive atomFamily initialized with props', async () => { + const myFamily = atomFamily((param: number) => atom(param)) + + const Displayer = ({ index }: { index: number }) => { + const [count, setCount] = useAtom(myFamily(index)) + return ( +
+ count: {count} + +
+ ) + } + + const Parent = () => { + const [index, setIndex] = useState(1) + + return ( +
+ + +
+ ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 1') + + fireEvent.click(getByText('button')) + await findByText('count: 11') + + fireEvent.click(getByText('increment')) + await findByText('count: 2') + + fireEvent.click(getByText('button')) + await findByText('count: 12') +}) + +it('derived atomFamily functionality as usual', async () => { + const arrayAtom = atom([0, 0, 0]) + + const myFamily = atomFamily((param: number) => + atom( + (get) => get(arrayAtom)[param] as number, + (_, set, update) => { + set(arrayAtom, (oldArray) => { + if (typeof oldArray[param] === 'undefined') return oldArray + + const newValue = + typeof update === 'function' + ? update(oldArray[param] as number) + : update + + const newArray = [ + ...oldArray.slice(0, param), + newValue, + ...oldArray.slice(param + 1), + ] + + return newArray + }) + } + ) + ) + + const Displayer = ({ + index, + countAtom, + }: { + index: number + countAtom: WritableAtom], void> + }) => { + const [count, setCount] = useAtom(countAtom) + return ( +
+ index: {index}, count: {count} + +
+ ) + } + + const indicesAtom = atom((get) => [...new Array(get(arrayAtom).length)]) + + const Parent = () => { + const [indices] = useAtom(indicesAtom) + + return ( +
+ {indices.map((_, index) => ( + + ))} +
+ ) + } + + const { getByText } = render( + + + + ) + + await waitFor(() => { + getByText('index: 0, count: 0') + getByText('index: 1, count: 0') + getByText('index: 2, count: 0') + }) + + fireEvent.click(getByText('increment #1')) + await waitFor(() => { + getByText('index: 0, count: 0') + getByText('index: 1, count: 1') + getByText('index: 2, count: 0') + }) + + fireEvent.click(getByText('increment #0')) + await waitFor(() => { + getByText('index: 0, count: 1') + getByText('index: 1, count: 1') + getByText('index: 2, count: 0') + }) + + fireEvent.click(getByText('increment #2')) + await waitFor(() => { + getByText('index: 0, count: 1') + getByText('index: 1, count: 1') + getByText('index: 2, count: 1') + }) +}) + +it('custom equality function work', async () => { + const bigAtom = atom([0]) + + const badFamily = atomFamily((num: { index: number }) => + atom((get) => get(bigAtom)[num.index] as number) + ) + + const goodFamily = atomFamily( + (num: { index: number }) => + atom((get) => get(bigAtom)[num.index] as number), + (l, r) => l.index === r.index + ) + + expect(badFamily({ index: 0 })).not.toEqual(badFamily({ index: 0 })) + expect(badFamily({ index: 0 })).not.toEqual(badFamily({ index: 0 })) + + expect(goodFamily({ index: 0 })).toEqual(goodFamily({ index: 0 })) + expect(goodFamily({ index: 0 })).not.toEqual(goodFamily({ index: 1 })) +}) + +it('a derived atom from an async atomFamily (#351)', async () => { + const countAtom = atom(1) + const resolve: (() => void)[] = [] + const getAsyncAtom = atomFamily((n: number) => + atom(async () => { + await new Promise((r) => resolve.push(r)) + return n + 10 + }) + ) + const derivedAtom = atom((get) => get(getAsyncAtom(get(countAtom)))) + + const Counter = () => { + const setCount = useSetAtom(countAtom) + const [derived] = useAtom(derivedAtom) + return ( + <> +
derived: {derived}
+ + + ) + } + + const { getByText, findByText } = render( + + + + + + ) + + await findByText('loading') + resolve.splice(0).forEach((fn) => fn()) + await findByText('derived: 11') + + fireEvent.click(getByText('button')) + await findByText('loading') + resolve.splice(0).forEach((fn) => fn()) + await findByText('derived: 12') + + fireEvent.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/vanilla/utils/atomWithDefault.test.tsx b/tests/vanilla/utils/atomWithDefault.test.tsx new file mode 100644 index 0000000000..888bf3ad21 --- /dev/null +++ b/tests/vanilla/utils/atomWithDefault.test.tsx @@ -0,0 +1,204 @@ +import { StrictMode, Suspense } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' +import { RESET, atomWithDefault } from 'jotai/vanilla/utils' + +it('simple sync get default', async () => { + const count1Atom = atom(1) + const count2Atom = atomWithDefault((get) => get(count1Atom) * 2) + + const Counter = () => { + const [count1, setCount1] = useAtom(count1Atom) + const [count2, setCount2] = useAtom(count2Atom) + return ( + <> +
+ count1: {count1}, count2: {count2} +
+ + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count1: 1, count2: 2') + + fireEvent.click(getByText('button1')) + await findByText('count1: 2, count2: 4') + + fireEvent.click(getByText('button2')) + await findByText('count1: 2, count2: 5') + + fireEvent.click(getByText('button1')) + await findByText('count1: 3, count2: 5') +}) + +it('simple async get default', async () => { + const count1Atom = atom(1) + let resolve = () => {} + const count2Atom = atomWithDefault(async (get) => { + await new Promise((r) => (resolve = r)) + return get(count1Atom) * 2 + }) + + const Counter = () => { + const [count1, setCount1] = useAtom(count1Atom) + const [count2, setCount2] = useAtom(count2Atom) + return ( + <> +
+ count1: {count1}, count2: {count2} +
+ + + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('loading') + resolve() + await findByText('count1: 1, count2: 2') + + fireEvent.click(getByText('button1')) + await findByText('loading') + resolve() + await findByText('count1: 2, count2: 4') + + fireEvent.click(getByText('button2')) + resolve() + await findByText('count1: 2, count2: 5') + + fireEvent.click(getByText('button1')) + resolve() + await findByText('count1: 3, count2: 5') +}) + +it('refresh sync atoms to default values', async () => { + const count1Atom = atom(1) + const count2Atom = atomWithDefault((get) => get(count1Atom) * 2) + + const Counter = () => { + const [count1, setCount1] = useAtom(count1Atom) + const [count2, setCount2] = useAtom(count2Atom) + return ( + <> +
+ count1: {count1}, count2: {count2} +
+ + + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count1: 1, count2: 2') + + fireEvent.click(getByText('button1')) + await findByText('count1: 2, count2: 4') + + fireEvent.click(getByText('button2')) + await findByText('count1: 2, count2: 5') + + fireEvent.click(getByText('button1')) + await findByText('count1: 3, count2: 5') + + fireEvent.click(getByText('Refresh count2')) + await findByText('count1: 3, count2: 6') + + fireEvent.click(getByText('button1')) + await findByText('count1: 4, count2: 8') +}) + +it('refresh async atoms to default values', async () => { + const count1Atom = atom(1) + let resolve = () => {} + const count2Atom = atomWithDefault(async (get) => { + await new Promise((r) => (resolve = r)) + return get(count1Atom) * 2 + }) + + const Counter = () => { + const [count1, setCount1] = useAtom(count1Atom) + const [count2, setCount2] = useAtom(count2Atom) + return ( + <> +
+ count1: {count1}, count2: {count2} +
+ + + + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('loading') + await waitFor(() => { + resolve() + getByText('count1: 1, count2: 2') + }) + + fireEvent.click(getByText('button1')) + if (process.env.PROVIDER_MODE !== 'VERSIONED_WRITE') { + // In VERSIONED_WRITE, this check is very unstable + await findByText('loading') + } + await waitFor(() => { + resolve() + getByText('count1: 2, count2: 4') + }) + + fireEvent.click(getByText('button2')) + await waitFor(() => { + resolve() + getByText('count1: 2, count2: 5') + }) + + fireEvent.click(getByText('button1')) + await waitFor(() => { + resolve() + getByText('count1: 3, count2: 5') + }) + + fireEvent.click(getByText('Refresh count2')) + await waitFor(() => { + resolve() + getByText('count1: 3, count2: 6') + }) + + fireEvent.click(getByText('button1')) + await waitFor(() => { + resolve() + getByText('count1: 4, count2: 8') + }) +}) diff --git a/tests/vanilla/utils/atomWithObservable.test.tsx b/tests/vanilla/utils/atomWithObservable.test.tsx new file mode 100644 index 0000000000..31d75aa0e8 --- /dev/null +++ b/tests/vanilla/utils/atomWithObservable.test.tsx @@ -0,0 +1,789 @@ +import { Component, StrictMode, Suspense, useState } from 'react' +import type { ReactElement, ReactNode } from 'react' +import { act, fireEvent, render, waitFor } from '@testing-library/react' +import { BehaviorSubject, Observable, Subject, delay, of } from 'rxjs' +import { fromValue, makeSubject, pipe, toObservable } from 'wonka' +import { useAtom, useAtomValue, useSetAtom } from 'jotai/react' +import { atom, createStore } from 'jotai/vanilla' +import { atomWithObservable } from 'jotai/vanilla/utils' + +beforeEach(() => { + jest.useFakeTimers() +}) +afterEach(() => { + jest.runAllTimers() + jest.useRealTimers() +}) + +class ErrorBoundary extends Component< + { children: ReactNode }, + { error: string } +> { + state = { + error: '', + } + + static getDerivedStateFromError(error: Error) { + return { error: error.message } + } + + render() { + if (this.state.error) { + return
Error: {this.state.error}
+ } + return this.props.children + } +} + +it('count state', async () => { + const observableAtom = atomWithObservable(() => of(1)) + + const Counter = () => { + const [state] = useAtom(observableAtom) + + return <>count: {state} + } + + const { findByText } = render( + + + + + + ) + + await findByText('count: 1') +}) + +it('writable count state', async () => { + const subject = new BehaviorSubject(1) + const observableAtom = atomWithObservable(() => subject) + + const Counter = () => { + const [state, dispatch] = useAtom(observableAtom) + return ( + <> + count: {state} + + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('count: 1') + + act(() => subject.next(2)) + await findByText('count: 2') + + fireEvent.click(getByText('button')) + await findByText('count: 9') + expect(subject.value).toBe(9) + + expect(subject) +}) + +it('writable count state without initial value', async () => { + const subject = new Subject() + const observableAtom = atomWithObservable(() => subject) + + const CounterValue = () => { + const state = useAtomValue(observableAtom) + return <>count: {state} + } + + const CounterButton = () => { + const dispatch = useSetAtom(observableAtom) + return + } + + const { findByText, getByText } = render( + + + + + + + ) + + await findByText('loading') + + fireEvent.click(getByText('button')) + await findByText('count: 9') + + act(() => subject.next(3)) + await findByText('count: 3') +}) + +it('writable count state with delayed value', async () => { + const subject = new Subject() + const observableAtom = atomWithObservable(() => { + const observable = of(1).pipe(delay(10 * 1000)) + observable.subscribe((n) => subject.next(n)) + return subject + }) + + const Counter = () => { + const [state, dispatch] = useAtom(observableAtom) + return ( + <> + count: {state} + + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('loading') + jest.runOnlyPendingTimers() + await findByText('count: 1') + + fireEvent.click(getByText('button')) + await findByText('count: 9') +}) + +it('only subscribe once per atom', async () => { + const subject = new Subject() + let totalSubscriptions = 0 + const observable = new Observable((subscriber) => { + totalSubscriptions++ + subject.subscribe(subscriber) + }) + const observableAtom = atomWithObservable(() => observable) + + const Counter = () => { + const [state] = useAtom(observableAtom) + return <>count: {state} + } + + const { findByText, rerender } = render( + <> + + + + + ) + await findByText('loading') + act(() => subject.next(1)) + await findByText('count: 1') + + rerender(
) + expect(totalSubscriptions).toEqual(1) + + rerender( + <> + + + + + ) + act(() => subject.next(2)) + await findByText('count: 2') + + expect(totalSubscriptions).toEqual(2) +}) + +it('cleanup subscription', async () => { + const subject = new Subject() + let activeSubscriptions = 0 + const observable = new Observable((subscriber) => { + activeSubscriptions++ + subject.subscribe(subscriber) + return () => { + activeSubscriptions-- + } + }) + const observableAtom = atomWithObservable(() => observable) + + const Counter = () => { + const [state] = useAtom(observableAtom) + return <>count: {state} + } + + const { findByText, rerender } = render( + + + + + + ) + + await findByText('loading') + + act(() => subject.next(1)) + await findByText('count: 1') + + expect(activeSubscriptions).toEqual(1) + rerender(
) + await waitFor(() => expect(activeSubscriptions).toEqual(0)) +}) + +it('resubscribe on remount', async () => { + const subject = new Subject() + const observableAtom = atomWithObservable(() => subject) + + const Counter = () => { + const [state] = useAtom(observableAtom) + return <>count: {state} + } + + const Toggle = ({ children }: { children: ReactElement }) => { + const [visible, setVisible] = useState(true) + return ( + <> + {visible && children} + + + ) + } + + const { findByText, getByText } = render( + + + + + + + + ) + + await findByText('loading') + act(() => subject.next(1)) + await findByText('count: 1') + + fireEvent.click(getByText('Toggle')) + fireEvent.click(getByText('Toggle')) + + act(() => subject.next(2)) + await findByText('count: 2') +}) + +it("count state with initialValue doesn't suspend", async () => { + const subject = new Subject() + const observableAtom = atomWithObservable(() => subject, { initialValue: 5 }) + + const Counter = () => { + const [state] = useAtom(observableAtom) + return <>count: {state} + } + + const { findByText } = render( + + + + ) + + await findByText('count: 5') + + act(() => subject.next(10)) + + await findByText('count: 10') +}) + +it('writable count state with initialValue', async () => { + const subject = new Subject() + const observableAtom = atomWithObservable(() => subject, { initialValue: 5 }) + + const Counter = () => { + const [state, dispatch] = useAtom(observableAtom) + return ( + <> + count: {state} + + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('count: 5') + act(() => subject.next(1)) + await findByText('count: 1') + + fireEvent.click(getByText('button')) + await findByText('count: 9') +}) + +it('writable count state with error', async () => { + const subject = new Subject() + const observableAtom = atomWithObservable(() => subject) + + const Counter = () => { + const [state, dispatch] = useAtom(observableAtom) + return ( + <> + count: {state} + + + ) + } + + const { findByText } = render( + + + + + + + + ) + + await findByText('loading') + + act(() => subject.error(new Error('Test Error'))) + await findByText('Error: Test Error') +}) + +it('synchronous subscription with initial value', async () => { + const observableAtom = atomWithObservable(() => of(1), { initialValue: 5 }) + + const Counter = () => { + const [state] = useAtom(observableAtom) + return <>count: {state} + } + + const { findByText } = render( + + + + ) + + await findByText('count: 1') +}) + +it('synchronous subscription with BehaviorSubject', async () => { + const observableAtom = atomWithObservable(() => new BehaviorSubject(1)) + + const Counter = () => { + const [state] = useAtom(observableAtom) + return <>count: {state} + } + + const { findByText } = render( + + + + ) + + await findByText('count: 1') +}) + +it('synchronous subscription with already emitted value', async () => { + const observableAtom = atomWithObservable(() => of(1)) + + const Counter = () => { + const [state] = useAtom(observableAtom) + + return <>count: {state} + } + + const { findByText } = render( + + + + ) + + await findByText('count: 1') +}) + +it('with falsy initial value', async () => { + const observableAtom = atomWithObservable(() => new Subject(), { + initialValue: 0, + }) + + const Counter = () => { + const [state] = useAtom(observableAtom) + return <>count: {state} + } + + const { findByText } = render( + + + + ) + + await findByText('count: 0') +}) + +it('with initially emitted undefined value', async () => { + const subject = new Subject() + const observableAtom = atomWithObservable(() => subject) + + const Counter = () => { + const [state] = useAtom(observableAtom) + return <>count: {state === undefined ? '-' : state} + } + + const { findByText } = render( + + + + + + ) + + await findByText('loading') + act(() => subject.next(undefined)) + await findByText('count: -') + act(() => subject.next(1)) + await findByText('count: 1') +}) + +it("don't omit values emitted between init and mount", async () => { + const subject = new Subject() + const observableAtom = atomWithObservable(() => subject) + + const Counter = () => { + const [state, dispatch] = useAtom(observableAtom) + return ( + <> + count: {state} + + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('loading') + act(() => { + subject.next(1) + subject.next(2) + }) + await findByText('count: 2') + + fireEvent.click(getByText('button')) + await findByText('count: 9') +}) + +describe('error handling', () => { + class ErrorBoundary extends Component< + { message?: string; retry?: () => void; children: ReactNode }, + { hasError: boolean } + > { + constructor(props: { message?: string; children: ReactNode }) { + super(props) + this.state = { hasError: false } + } + static getDerivedStateFromError() { + return { hasError: true } + } + render() { + return this.state.hasError ? ( +
+ {this.props.message || 'errored'} + {this.props.retry && ( + + )} +
+ ) : ( + this.props.children + ) + } + } + + it('can catch error in error boundary', async () => { + const subject = new Subject() + const countAtom = atomWithObservable(() => subject) + + const Counter = () => { + const [count] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + ) + } + + const { findByText } = render( + + + + + + + + ) + + await findByText('loading') + act(() => subject.error(new Error('Test Error'))) + await findByText('errored') + }) + + it('can recover from error with dependency', async () => { + const baseAtom = atom(0) + const countAtom = atomWithObservable((get) => { + const base = get(baseAtom) + if (base % 2 === 0) { + const subject = new Subject() + const observable = of(1).pipe(delay(10 * 1000)) + observable.subscribe(() => subject.error(new Error('Test Error'))) + return subject + } + const observable = of(base).pipe(delay(10 * 1000)) + return observable + }) + + const Counter = () => { + const [count] = useAtom(countAtom) + const setBase = useSetAtom(baseAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const App = () => { + const setBase = useSetAtom(baseAtom) + const retry = () => { + setBase((c) => c + 1) + } + return ( + + + + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('loading') + jest.runOnlyPendingTimers() + await findByText('errored') + + fireEvent.click(getByText('retry')) + await findByText('loading') + jest.runOnlyPendingTimers() + await findByText('count: 1') + + fireEvent.click(getByText('next')) + await findByText('loading') + jest.runOnlyPendingTimers() + await findByText('errored') + + fireEvent.click(getByText('retry')) + await findByText('loading') + jest.runOnlyPendingTimers() + await findByText('count: 3') + }) + + it('can recover with intermediate atom', async () => { + let count = -1 + let willThrowError = false + const refreshAtom = atom(0) + const countObservableAtom = atom((get) => { + get(refreshAtom) + const observableAtom = atomWithObservable(() => { + willThrowError = !willThrowError + ++count + const subject = new Subject<{ data: number } | { error: Error }>() + setTimeout(() => { + if (willThrowError) { + subject.next({ error: new Error('Test Error') }) + } else { + subject.next({ data: count }) + } + }, 10 * 1000) + return subject + }) + return observableAtom + }) + const derivedAtom = atom((get) => { + const observableAtom = get(countObservableAtom) + const result = get(observableAtom) + if (result instanceof Promise) { + return result.then((result) => { + if ('error' in result) { + throw result.error + } + return result.data + }) + } + if ('error' in result) { + throw result.error + } + return result.data + }) + + const Counter = () => { + const [count] = useAtom(derivedAtom) + const refresh = useSetAtom(refreshAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const App = () => { + const refresh = useSetAtom(refreshAtom) + const retry = () => { + refresh((c) => c + 1) + } + return ( + + + + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('loading') + jest.runOnlyPendingTimers() + await findByText('errored') + + fireEvent.click(getByText('retry')) + await findByText('loading') + jest.runOnlyPendingTimers() + await findByText('count: 1') + + fireEvent.click(getByText('refresh')) + await findByText('loading') + jest.runOnlyPendingTimers() + await findByText('errored') + + fireEvent.click(getByText('retry')) + await findByText('loading') + jest.runOnlyPendingTimers() + await findByText('count: 3') + }) +}) + +describe('wonka', () => { + it('count state', async () => { + const source = fromValue(1) + const observable = pipe(source, toObservable) + const observableAtom = atomWithObservable(() => observable) + + const Counter = () => { + const [count] = useAtom(observableAtom) + return <>count: {count} + } + + const { findByText } = render( + + + + + + ) + + await findByText('count: 1') + }) + + it('make subject', async () => { + const subject = makeSubject() + const observable = pipe(subject.source, toObservable) + const observableAtom = atomWithObservable(() => observable) + const countAtom = atom( + (get) => get(observableAtom), + (_get, _set, nextValue: number) => { + subject.next(nextValue) + } + ) + + const Counter = () => { + const [count] = useAtom(countAtom) + return <>count: {count} + } + + const Controls = () => { + const setCount = useSetAtom(countAtom) + return + } + + const { findByText, getByText } = render( + + + + + + + ) + + await findByText('loading') + + fireEvent.click(getByText('button')) + await findByText('count: 1') + }) +}) + +describe('atomWithObservable vanilla tests', () => { + it('can propagate updates with async atom chains', async () => { + const store = createStore() + + const subject = new BehaviorSubject(1) + const countAtom = atomWithObservable(() => subject) + const asyncAtom = atom(async (get) => get(countAtom)) + const async2Atom = atom((get) => get(asyncAtom)) + + const unsub = store.sub(async2Atom, () => {}) + + await expect(store.get(async2Atom)).resolves.toBe(1) + + subject.next(2) + await expect(store.get(async2Atom)).resolves.toBe(2) + + subject.next(3) + await expect(store.get(async2Atom)).resolves.toBe(3) + + unsub() + }) +}) diff --git a/tests/vanilla/utils/atomWithReducer.test.tsx b/tests/vanilla/utils/atomWithReducer.test.tsx new file mode 100644 index 0000000000..11b7eec40b --- /dev/null +++ b/tests/vanilla/utils/atomWithReducer.test.tsx @@ -0,0 +1,84 @@ +import { StrictMode } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { useAtom } from 'jotai/react' +import { atomWithReducer } from 'jotai/vanilla/utils' + +it('atomWithReducer with optional action argument', async () => { + const reducer = (state: number, action?: 'INCREASE' | 'DECREASE') => { + switch (action) { + case 'INCREASE': + return state + 1 + case 'DECREASE': + return state - 1 + case undefined: + return state + } + } + const countAtom = atomWithReducer(0, reducer) + + const Parent = () => { + const [count, dispatch] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('dispatch INCREASE')) + await findByText('count: 1') + + fireEvent.click(getByText('dispatch empty')) + await findByText('count: 1') + + fireEvent.click(getByText('dispatch DECREASE')) + await findByText('count: 0') +}) + +it('atomWithReducer with non-optional action argument', async () => { + const reducer = (state: number, action: 'INCREASE' | 'DECREASE') => { + switch (action) { + case 'INCREASE': + return state + 1 + case 'DECREASE': + return state - 1 + } + } + const countAtom = atomWithReducer(0, reducer) + + const Parent = () => { + const [count, dispatch] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('dispatch INCREASE')) + await findByText('count: 1') + + fireEvent.click(getByText('dispatch DECREASE')) + await findByText('count: 0') +}) diff --git a/tests/vanilla/utils/atomWithStorage.test.tsx b/tests/vanilla/utils/atomWithStorage.test.tsx new file mode 100644 index 0000000000..7758dd270c --- /dev/null +++ b/tests/vanilla/utils/atomWithStorage.test.tsx @@ -0,0 +1,345 @@ +import { StrictMode, Suspense } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useAtom } from 'jotai/react' +import { + unstable_NO_STORAGE_VALUE as NO_STORAGE_VALUE, + RESET, + atomWithStorage, + createJSONStorage, +} from 'jotai/vanilla/utils' + +const resolve: (() => void)[] = [] + +describe('atomWithStorage (sync)', () => { + const storageData: Record = { + count: 10, + } + const dummyStorage = { + getItem: (key: string) => { + if (!(key in storageData)) { + return NO_STORAGE_VALUE + } + return storageData[key] as number + }, + setItem: (key: string, newValue: number) => { + storageData[key] = newValue + dummyStorage.listeners.forEach((listener) => { + listener(key, newValue) + }) + }, + removeItem: (key: string) => { + delete storageData[key] + }, + listeners: new Set<(key: string, value: number) => void>(), + subscribe: (key: string, callback: (value: number) => void) => { + const listener = (k: string, value: number) => { + if (k === key) { + callback(value) + } + } + dummyStorage.listeners.add(listener) + return () => dummyStorage.listeners.delete(listener) + }, + } + + it('simple count', async () => { + const countAtom = atomWithStorage('count', 1, dummyStorage) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 10') + + fireEvent.click(getByText('button')) + await findByText('count: 11') + expect(storageData.count).toBe(11) + + fireEvent.click(getByText('reset')) + await findByText('count: 1') + expect(storageData.count).toBeUndefined() + }) + + it('storage updates before mount (#1079)', async () => { + dummyStorage.setItem('count', 10) + const countAtom = atomWithStorage('count', 1, dummyStorage) + + const Counter = () => { + const [count] = useAtom(countAtom) + // emulating updating before mount + if (dummyStorage.getItem('count') !== 9) { + dummyStorage.setItem('count', 9) + } + return
count: {count}
+ } + + const { findByText } = render( + + + + ) + + await findByText('count: 9') + }) +}) + +describe('with sync string storage', () => { + const storageData: Record = { + count: '10', + } + const stringStorage = { + getItem: (key: string) => { + return storageData[key] || null + }, + setItem: (key: string, newValue: string) => { + storageData[key] = newValue + stringStorage.listeners.forEach((listener) => { + listener(key, newValue) + }) + }, + removeItem: (key: string) => { + delete storageData[key] + }, + listeners: new Set<(key: string, value: string) => void>(), + } + const dummyStorage = createJSONStorage(() => stringStorage) + dummyStorage.subscribe = (key, callback) => { + const listener = (k: string, value: string) => { + if (k === key) { + callback(JSON.parse(value)) + } + } + stringStorage.listeners.add(listener) + return () => stringStorage.listeners.delete(listener) + } + + it('simple count', async () => { + const countAtom = atomWithStorage('count', 1, dummyStorage) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + + + ) + } + + const { findByText, getByText } = render( + + + + ) + + await findByText('count: 10') + + fireEvent.click(getByText('button')) + await findByText('count: 11') + expect(storageData.count).toBe('11') + + fireEvent.click(getByText('reset')) + await findByText('count: 1') + expect(storageData.count).toBeUndefined() + + fireEvent.click(getByText('button')) + await findByText('count: 2') + expect(storageData.count).toBe('2') + + fireEvent.click(getByText('conditional reset')) + await findByText('count: 1') + expect(storageData.count).toBeUndefined() + }) + + it('no entry (#1086)', async () => { + const noentryAtom = atomWithStorage('noentry', -1, dummyStorage) + + const Counter = () => { + const [noentry] = useAtom(noentryAtom) + return
noentry: {noentry}
+ } + + const { findByText } = render( + + + + ) + + await findByText('noentry: -1') + }) +}) + +describe('atomWithStorage (async)', () => { + const asyncStorageData: Record = { + count: 10, + } + const asyncDummyStorage = { + getItem: async (key: string) => { + await new Promise((r) => resolve.push(r)) + if (!(key in asyncStorageData)) { + return NO_STORAGE_VALUE + } + return asyncStorageData[key] as number + }, + setItem: async (key: string, newValue: number) => { + await new Promise((r) => resolve.push(r)) + asyncStorageData[key] = newValue + }, + removeItem: async (key: string) => { + await new Promise((r) => resolve.push(r)) + delete asyncStorageData[key] + }, + } + + it('async count', async () => { + const countAtom = atomWithStorage('count', 1, asyncDummyStorage) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 10') + + fireEvent.click(getByText('button')) + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 11') + await waitFor(() => { + expect(asyncStorageData.count).toBe(11) + }) + + fireEvent.click(getByText('reset')) + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 1') + await waitFor(() => { + expect(asyncStorageData.count).toBeUndefined() + }) + }) + + it('async new count', async () => { + const countAtom = atomWithStorage('count2', 20, asyncDummyStorage) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('count: 20') + + fireEvent.click(getByText('button')) + resolve.splice(0).forEach((fn) => fn()) + await findByText('count: 21') + await waitFor(() => { + expect(asyncStorageData.count2).toBe(21) + }) + }) +}) + +describe('atomWithStorage (without localStorage) (#949)', () => { + it('createJSONStorage without localStorage', async () => { + const countAtom = atomWithStorage( + 'count', + 1, + createJSONStorage(() => undefined as any) + ) + + const Counter = () => { + const [count, setCount] = useAtom(countAtom) + return ( + <> +
count: {count}
+ + + ) + } + + const { findByText } = render( + + + + ) + + await findByText('count: 1') + }) +}) + +describe('atomWithStorage (in non-browser environment)', () => { + const asyncStorageData: Record = { + count: '10', + } + const asyncDummyStorage = { + getItem: async (key: string) => { + await new Promise((r) => resolve.push(r)) + return asyncStorageData[key] as string + }, + setItem: async (key: string, newValue: string) => { + await new Promise((r) => resolve.push(r)) + asyncStorageData[key] = newValue + }, + removeItem: async (key: string) => { + await new Promise((r) => resolve.push(r)) + delete asyncStorageData[key] + }, + } + + const addEventListener = window.addEventListener + + beforeAll(() => { + ;(window as any).addEventListener = undefined + }) + + afterAll(() => { + window.addEventListener = addEventListener + }) + + it('createJSONStorage with undefined window.addEventListener', async () => { + const storage = createJSONStorage(() => asyncDummyStorage) + + expect(storage.subscribe).toBeUndefined() + }) +}) diff --git a/tests/vanilla/utils/freezeAtom.test.tsx b/tests/vanilla/utils/freezeAtom.test.tsx new file mode 100644 index 0000000000..35432cf758 --- /dev/null +++ b/tests/vanilla/utils/freezeAtom.test.tsx @@ -0,0 +1,42 @@ +import { StrictMode } from 'react' +import { render } from '@testing-library/react' +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({ count: 0 }) + + const Component = () => { + const [obj] = useAtom(freezeAtom(objAtom)) + + return
isFrozen: {`${Object.isFrozen(obj)}`}
+ } + + const { findByText } = render( + + + + ) + + await findByText('isFrozen: true') +}) + +it('freezeAtomCreator basic test', async () => { + const createFrozenAtom = freezeAtomCreator(atom) + const objAtom = createFrozenAtom({ count: 0 }) + + const Component = () => { + const [obj] = useAtom(objAtom) + + return
isFrozen: {`${Object.isFrozen(obj)}`}
+ } + + const { findByText } = render( + + + + ) + + await findByText('isFrozen: true') +}) diff --git a/tests/vanilla/utils/loadable.test.tsx b/tests/vanilla/utils/loadable.test.tsx new file mode 100644 index 0000000000..f1adfc1876 --- /dev/null +++ b/tests/vanilla/utils/loadable.test.tsx @@ -0,0 +1,286 @@ +import { StrictMode, Suspense, useEffect } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { useAtomValue, useSetAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' +import type { Atom } from 'jotai/vanilla' +import { loadable } from 'jotai/vanilla/utils' + +it('loadable turns suspense into values', async () => { + let resolve: (x: number) => void = () => {} + const asyncAtom = atom(() => { + return new Promise((r) => (resolve = r)) + }) + + const { findByText } = render( + + + + ) + + await findByText('Loading...') + resolve(5) + await findByText('Data: 5') +}) + +it('loadable turns errors into values', async () => { + let reject: (error: unknown) => void = () => {} + const asyncAtom = atom(() => { + return new Promise((_res, rej) => (reject = rej)) + }) + + const { findByText } = render( + + + + ) + + await findByText('Loading...') + reject(new Error('An error occurred')) + await findByText('Error: An error occurred') +}) + +it('loadable turns primitive throws into values', async () => { + let reject: (error: unknown) => void = () => {} + const asyncAtom = atom(() => { + return new Promise((_res, rej) => (reject = rej)) + }) + + const { findByText } = render( + + + + ) + + await findByText('Loading...') + reject('An error occurred') + await findByText('An error occurred') +}) + +it('loadable goes back to loading after re-fetch', async () => { + let resolve: (x: number) => void = () => {} + const refreshAtom = atom(0) + const asyncAtom = atom((get) => { + get(refreshAtom) + return new Promise((r) => (resolve = r)) + }) + + const Refresh = () => { + const setRefresh = useSetAtom(refreshAtom) + return ( + <> + + + ) + } + + const { findByText, getByText } = render( + + + + + ) + + getByText('Loading...') + resolve(5) + await findByText('Data: 5') + fireEvent.click(getByText('refresh')) + await findByText('Loading...') + resolve(6) + await findByText('Data: 6') +}) + +it('loadable can recover from error', async () => { + let resolve: (x: number) => void = () => {} + let reject: (error: unknown) => void = () => {} + const refreshAtom = atom(0) + const asyncAtom = atom((get) => { + get(refreshAtom) + return new Promise((res, rej) => { + resolve = res + reject = rej + }) + }) + + const Refresh = () => { + const setRefresh = useSetAtom(refreshAtom) + return ( + <> + + + ) + } + + const { findByText, getByText } = render( + + + + + ) + + getByText('Loading...') + reject(new Error('An error occurred')) + await findByText('Error: An error occurred') + fireEvent.click(getByText('refresh')) + await findByText('Loading...') + resolve(6) + await findByText('Data: 6') +}) + +it('loadable immediately resolves sync values', async () => { + const syncAtom = atom(5) + const effectCallback = jest.fn() + + const { getByText } = render( + + + + ) + + getByText('Data: 5') + expect(effectCallback.mock.calls).not.toContain( + expect.objectContaining({ state: 'loading' }) + ) + expect(effectCallback).toHaveBeenLastCalledWith({ state: 'hasData', data: 5 }) +}) + +it('loadable can use resolved promises synchronously', async () => { + const asyncAtom = atom(Promise.resolve(5)) + const effectCallback = jest.fn() + + const ResolveAtomComponent = () => { + useAtomValue(asyncAtom) + + return
Ready
+ } + + const { findByText, rerender } = render( + + + + + + ) + + await findByText('Ready') + + rerender( + + + + ) + await findByText('Data: 5') + + expect(effectCallback.mock.calls).not.toContain( + expect.objectContaining({ state: 'loading' }) + ) + expect(effectCallback).toHaveBeenLastCalledWith({ state: 'hasData', data: 5 }) +}) + +it('loadable of a derived async atom does not trigger infinite loop (#1114)', async () => { + let resolve: (x: number) => void = () => {} + const baseAtom = atom(0) + const asyncAtom = atom((get) => { + get(baseAtom) + return new Promise((r) => (resolve = r)) + }) + + const Trigger = () => { + const trigger = useSetAtom(baseAtom) + return ( + <> + + + ) + } + + const { findByText, getByText } = render( + + + + + ) + + getByText('Loading...') + fireEvent.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) +}) + +type LoadableComponentProps = { + asyncAtom: Atom | Promise | string | number> + effectCallback?: (loadableValue: any) => void +} + +const LoadableComponent = ({ + asyncAtom, + effectCallback, +}: LoadableComponentProps) => { + const value = useAtomValue(loadable(asyncAtom)) + + useEffect(() => { + if (effectCallback) { + effectCallback(value) + } + }, [value, effectCallback]) + + if (value.state === 'loading') { + return <>Loading... + } + + if (value.state === 'hasError') { + return <>{String(value.error)} + } + + // this is to ensure correct typing + const data: number | string = value.data + + return <>Data: {data} +} diff --git a/tests/vanilla/utils/selectAtom.test.tsx b/tests/vanilla/utils/selectAtom.test.tsx new file mode 100644 index 0000000000..204eed7e1a --- /dev/null +++ b/tests/vanilla/utils/selectAtom.test.tsx @@ -0,0 +1,228 @@ +import { StrictMode, Suspense, useEffect, useRef } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { useAtom, useAtomValue, useSetAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' +import { selectAtom } from 'jotai/vanilla/utils' + +const useCommitCount = () => { + const commitCountRef = useRef(1) + useEffect(() => { + commitCountRef.current += 1 + }) + return commitCountRef.current +} + +it('selectAtom works as expected', async () => { + const bigAtom = atom({ a: 0, b: 'othervalue' }) + const littleAtom = selectAtom(bigAtom, (v) => v.a) + + const Parent = () => { + const setValue = useSetAtom(bigAtom) + return ( + <> + + + ) + } + + const Selector = () => { + const a = useAtomValue(littleAtom) + return ( + <> +
a: {a}
+ + ) + } + + const { findByText, getByText } = render( + + + + + ) + + await findByText('a: 0') + + fireEvent.click(getByText('increment')) + await findByText('a: 1') + fireEvent.click(getByText('increment')) + await findByText('a: 2') + fireEvent.click(getByText('increment')) + await findByText('a: 3') +}) + +it('selectAtom works with async atom', async () => { + const bigAtom = atom({ a: 0, b: 'othervalue' }) + const bigAtomAsync = atom((get) => Promise.resolve(get(bigAtom))) + const littleAtom = selectAtom(bigAtomAsync, (v) => v.a) + + const Parent = () => { + const setValue = useSetAtom(bigAtom) + return ( + <> + + + ) + } + + const Selector = () => { + const a = useAtomValue(littleAtom) + return ( + <> +
a: {a}
+ + ) + } + + const { findByText, getByText } = render( + + + + + + + ) + + await findByText('a: 0') + + fireEvent.click(getByText('increment')) + await findByText('a: 1') + fireEvent.click(getByText('increment')) + await findByText('a: 2') + fireEvent.click(getByText('increment')) + await findByText('a: 3') +}) + +it('do not update unless equality function says value has changed', async () => { + const bigAtom = atom({ a: 0 }) + const littleAtom = selectAtom( + bigAtom, + (value) => value, + (left, right) => JSON.stringify(left) === JSON.stringify(right) + ) + + const Parent = () => { + const setValue = useSetAtom(bigAtom) + return ( + <> + + + + ) + } + + const Selector = () => { + const value = useAtomValue(littleAtom) + const commits = useCommitCount() + return ( + <> +
value: {JSON.stringify(value)}
+
commits: {commits}
+ + ) + } + + const { findByText, getByText } = render( + <> + + + + ) + + await findByText('value: {"a":0}') + await findByText('commits: 1') + fireEvent.click(getByText('copy')) + await findByText('value: {"a":0}') + await findByText('commits: 1') + + fireEvent.click(getByText('increment')) + await findByText('value: {"a":1}') + await findByText('commits: 2') + fireEvent.click(getByText('copy')) + await findByText('value: {"a":1}') + await findByText('commits: 2') + + fireEvent.click(getByText('increment')) + await findByText('value: {"a":2}') + await findByText('commits: 3') + fireEvent.click(getByText('copy')) + await findByText('value: {"a":2}') + await findByText('commits: 3') + + fireEvent.click(getByText('increment')) + await findByText('value: {"a":3}') + await findByText('commits: 4') + fireEvent.click(getByText('copy')) + await findByText('value: {"a":3}') + await findByText('commits: 4') +}) + +it('equality function works even if suspend', async () => { + const bigAtom = atom({ a: 0 }) + const bigAtomAsync = atom((get) => Promise.resolve(get(bigAtom))) + const littleAtom = selectAtom( + bigAtomAsync, + (value) => value, + (left, right) => left.a === right.a + ) + + const Controls = () => { + const [value, setValue] = useAtom(bigAtom) + return ( + <> +
bigValue: {JSON.stringify(value)}
+ + + + ) + } + + const Selector = () => { + const value = useAtomValue(littleAtom) + return
littleValue: {JSON.stringify(value)}
+ } + + const { findByText, getByText } = render( + + + + + + + ) + + await findByText('bigValue: {"a":0}') + await findByText('littleValue: {"a":0}') + + fireEvent.click(getByText('increment')) + await findByText('bigValue: {"a":1}') + await findByText('littleValue: {"a":1}') + + fireEvent.click(getByText('other')) + await findByText('bigValue: {"a":1,"b":2}') + await findByText('littleValue: {"a":1}') +}) diff --git a/tests/vanilla/utils/splitAtom.test.tsx b/tests/vanilla/utils/splitAtom.test.tsx new file mode 100644 index 0000000000..99aea21de3 --- /dev/null +++ b/tests/vanilla/utils/splitAtom.test.tsx @@ -0,0 +1,511 @@ +import { StrictMode, useEffect, useRef } from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import { useAtom, useSetAtom } from 'jotai/react' +import { atom } from 'jotai/vanilla' +import type { Atom, PrimitiveAtom } from 'jotai/vanilla' +import { splitAtom } from 'jotai/vanilla/utils' + +type TodoItem = { task: string; checked?: boolean } + +const useCommitCount = () => { + const commitCountRef = useRef(1) + useEffect(() => { + commitCountRef.current += 1 + }) + return commitCountRef.current +} + +it('no unnecessary updates when updating atoms', async () => { + const todosAtom = atom([ + { task: 'get cat food', checked: false }, + { task: 'get dragon food', checked: false }, + ]) + + const TaskList = ({ listAtom }: { listAtom: typeof todosAtom }) => { + const [atoms, dispatch] = useAtom(splitAtom(listAtom)) + return ( + <> + TaskListUpdates: {useCommitCount()} + {atoms.map((anAtom) => ( + dispatch({ type: 'remove', atom: anAtom })} + itemAtom={anAtom} + /> + ))} + + ) + } + + const TaskItem = ({ + itemAtom, + }: { + itemAtom: PrimitiveAtom + onRemove: () => void + }) => { + const [value, onChange] = useAtom(itemAtom) + const toggle = () => + onChange((value) => ({ ...value, checked: !value.checked })) + return ( +
  • + {value.task} commits: {useCommitCount()} + +
  • + ) + } + + const { getByTestId, getByText } = render( + <> + + + ) + + await waitFor(() => { + getByText('TaskListUpdates: 1') + getByText('get cat food commits: 1') + getByText('get dragon food commits: 1') + }) + + const catBox = getByTestId('get cat food-checkbox') as HTMLInputElement + const dragonBox = getByTestId('get dragon food-checkbox') as HTMLInputElement + + expect(catBox.checked).toBe(false) + expect(dragonBox.checked).toBe(false) + + fireEvent.click(catBox) + + await waitFor(() => { + getByText('TaskListUpdates: 1') + getByText('get cat food commits: 2') + getByText('get dragon food commits: 1') + }) + + expect(catBox.checked).toBe(true) + expect(dragonBox.checked).toBe(false) + + fireEvent.click(dragonBox) + + await waitFor(() => { + getByText('TaskListUpdates: 1') + getByText('get cat food commits: 2') + getByText('get dragon food commits: 2') + }) + + expect(catBox.checked).toBe(true) + expect(dragonBox.checked).toBe(true) +}) + +it('removing atoms', async () => { + const todosAtom = atom([ + { task: 'get cat food', checked: false }, + { task: 'get dragon food', checked: false }, + { task: 'help nana', checked: false }, + ]) + + const TaskList = ({ listAtom }: { listAtom: typeof todosAtom }) => { + const [atoms, dispatch] = useAtom(splitAtom(listAtom)) + return ( + <> + {atoms.map((anAtom) => ( + dispatch({ type: 'remove', atom: anAtom })} + itemAtom={anAtom} + /> + ))} + + ) + } + + const TaskItem = ({ + itemAtom, + onRemove, + }: { + itemAtom: PrimitiveAtom + onRemove: () => void + }) => { + const [value] = useAtom(itemAtom) + return ( +
  • +
    {value.task}
    + +
  • + ) + } + + const { getByTestId, queryByText } = render( + + + + ) + + await waitFor(() => { + expect(queryByText('get cat food')).toBeTruthy() + expect(queryByText('get dragon food')).toBeTruthy() + expect(queryByText('help nana')).toBeTruthy() + }) + + fireEvent.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() + }) + + fireEvent.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() + }) + + fireEvent.click(getByTestId('help nana-removebutton')) + + await waitFor(() => { + expect(queryByText('get cat food')).toBeFalsy() + expect(queryByText('get dragon food')).toBeFalsy() + expect(queryByText('help nana')).toBeFalsy() + }) +}) + +it('inserting atoms', async () => { + const todosAtom = atom([ + { task: 'get cat food' }, + { task: 'get dragon food' }, + { task: 'help nana' }, + ]) + + const TaskList = ({ listAtom }: { listAtom: typeof todosAtom }) => { + const [atoms, dispatch] = useAtom(splitAtom(listAtom)) + return ( + <> +
      + {atoms.map((anAtom) => ( + + dispatch({ + type: 'insert', + value: newValue, + before: anAtom, + }) + } + itemAtom={anAtom} + /> + ))} +
    + + + ) + } + + let taskCount = 1 + const TaskItem = ({ + itemAtom, + onInsert, + }: { + itemAtom: PrimitiveAtom + onInsert: (newValue: TodoItem) => void + }) => { + const [value] = useAtom(itemAtom) + return ( +
  • +
    {value.task}
    + +
  • + ) + } + + const { getByTestId, queryByTestId } = render( + + + + ) + + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'get cat food+get dragon food+help nana+' + ) + }) + + fireEvent.click(getByTestId('help nana-insertbutton')) + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'get cat food+get dragon food+new task1+help nana+' + ) + }) + + fireEvent.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+' + ) + }) + + fireEvent.click(getByTestId('addtaskbutton')) + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'new task2+get cat food+get dragon food+new task1+help nana+end+' + ) + }) +}) + +it('moving atoms', async () => { + const todosAtom = atom([ + { task: 'get cat food' }, + { task: 'get dragon food' }, + { task: 'help nana' }, + ]) + + const TaskList = ({ listAtom }: { listAtom: typeof todosAtom }) => { + const [atoms, dispatch] = useAtom(splitAtom(listAtom)) + return ( +
      + {atoms.map((anAtom, index) => ( + { + if (index > 0) { + dispatch({ + type: 'move', + atom: anAtom, + before: atoms[index - 1] as PrimitiveAtom, + }) + } + }} + onMoveRight={() => { + if (index === atoms.length - 1) { + dispatch({ + type: 'move', + atom: anAtom, + }) + } else if (index < atoms.length - 1) { + dispatch({ + type: 'move', + atom: anAtom, + before: atoms[index + 2] as PrimitiveAtom, + }) + } + }} + itemAtom={anAtom} + /> + ))} +
    + ) + } + + const TaskItem = ({ + itemAtom, + onMoveLeft, + onMoveRight, + }: { + itemAtom: PrimitiveAtom + onMoveLeft: () => void + onMoveRight: () => void + }) => { + const [value] = useAtom(itemAtom) + return ( +
  • +
    {value.task}
    + + +
  • + ) + } + + const { getByTestId, queryByTestId } = render( + + + + ) + + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'get cat food<>get dragon food<>help nana<>' + ) + }) + + fireEvent.click(getByTestId('help nana-leftbutton')) + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'get cat food<>help nana<>get dragon food<>' + ) + }) + + fireEvent.click(getByTestId('get cat food-rightbutton')) + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'help nana<>get cat food<>get dragon food<>' + ) + }) + + fireEvent.click(getByTestId('get cat food-rightbutton')) + await waitFor(() => { + expect(queryByTestId('list')?.textContent).toBe( + 'help nana<>get dragon food<>get cat food<>' + ) + }) +}) + +it('read-only array atom', async () => { + const todosAtom = atom(() => [ + { task: 'get cat food', checked: false }, + { task: 'get dragon food', checked: false }, + ]) + + const TaskList = ({ listAtom }: { listAtom: typeof todosAtom }) => { + const [atoms] = useAtom(splitAtom(listAtom)) + return ( + <> + {atoms.map((anAtom) => ( + + ))} + + ) + } + + const TaskItem = ({ itemAtom }: { itemAtom: Atom }) => { + const [value] = useAtom(itemAtom) + return ( +
  • + +
  • + ) + } + + const { getByTestId } = render( + + + + ) + + const catBox = getByTestId('get cat food-checkbox') as HTMLInputElement + const dragonBox = getByTestId('get dragon food-checkbox') as HTMLInputElement + + await waitFor(() => { + expect(catBox.checked).toBe(false) + expect(dragonBox.checked).toBe(false) + }) +}) + +it('no error with cached atoms (fix 510)', async () => { + const filterAtom = atom('all') + const numsAtom = atom([0, 1, 2, 3, 4]) + const filteredAtom = atom((get) => { + const filter = get(filterAtom) + const nums = get(numsAtom) + if (filter === 'even') { + return nums.filter((num) => num % 2 === 0) + } + return nums + }) + const filteredAtomsAtom = splitAtom(filteredAtom, (num) => num) + + function useCachedAtoms(atoms: T[]) { + const prevAtoms = useRef(atoms) + return prevAtoms.current + } + + type NumItemProps = { atom: Atom } + + const NumItem = ({ atom }: NumItemProps) => { + const [readOnlyItem] = useAtom(atom) + if (typeof readOnlyItem !== 'number') { + throw new Error('expecting a number') + } + return <>{readOnlyItem} + } + + function Filter() { + const [, setFilter] = useAtom(filterAtom) + return + } + + const Filtered = () => { + const [todos] = useAtom(filteredAtomsAtom) + const cachedAtoms = useCachedAtoms(todos) + + return ( + <> + {cachedAtoms.map((atom) => ( + + ))} + + ) + } + + const { getByText } = render( + + + + + ) + + fireEvent.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') + + fireEvent.click(getByText('button')) + await findByText('numbers: 1,2') +}) diff --git a/tests/vanilla/utils/unwrapAtom.test.tsx b/tests/vanilla/utils/unwrapAtom.test.tsx new file mode 100644 index 0000000000..d283c7e30b --- /dev/null +++ b/tests/vanilla/utils/unwrapAtom.test.tsx @@ -0,0 +1,30 @@ +import { atom, createStore } from 'jotai/vanilla' +import { unstable_unwrapAtom as unwrapAtom } from 'jotai/vanilla/utils' + +describe('unwrapAtom', () => { + it('should unwrap a promise', 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 = unwrapAtom(asyncAtom, 0) + expect(store.get(syncAtom)).toBe(0) + resolve() + await new Promise((r) => setTimeout(r)) // wait 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 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 a tick + expect(store.get(syncAtom)).toBe(6) + }) +})