From d147e958f9bcc5247445f11789af3ebc4af4ad19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Stabell?= Date: Wed, 20 Nov 2024 17:08:09 -0800 Subject: [PATCH] WIP: first checkin of atomicStore see https://github.com/jotaijs/jotai-zustand/issues/7#issuecomment-2465970828 --- ATOMIC-STORE.md | 155 ++++++++++ examples/01_typescript/package.json | 2 +- examples/02_create/package.json | 2 +- examples/03_atomic/index.html | 9 + examples/03_atomic/package.json | 22 ++ examples/03_atomic/src/app.tsx | 40 +++ examples/03_atomic/src/main.tsx | 10 + examples/03_atomic/tsconfig.json | 14 + package.json | 3 +- src/atomicStore.ts | 267 ++++++++++++++++++ src/index.ts | 1 + tests/03_atomicStore.spec.tsx | 421 ++++++++++++++++++++++++++++ tsconfig.json | 3 +- 13 files changed, 945 insertions(+), 4 deletions(-) create mode 100644 ATOMIC-STORE.md create mode 100644 examples/03_atomic/index.html create mode 100644 examples/03_atomic/package.json create mode 100644 examples/03_atomic/src/app.tsx create mode 100644 examples/03_atomic/src/main.tsx create mode 100644 examples/03_atomic/tsconfig.json create mode 100644 src/atomicStore.ts create mode 100644 tests/03_atomicStore.spec.tsx diff --git a/ATOMIC-STORE.md b/ATOMIC-STORE.md new file mode 100644 index 0000000..66914a4 --- /dev/null +++ b/ATOMIC-STORE.md @@ -0,0 +1,155 @@ +Here's an updated spec/README — I'm trying out some nomenclature (atomic store, computeds => derived state) to see if it makes it easier/simpler. + +It struck me that it is presumably possible to make this atomic store completely Zustand compatible, and it would probably be possible to wrap a Zustand store to make it an atomic store — it wouldn't have derived state, but you could add that if you wanted to. + +# Atomic Store + +An atomic store is a type inferred central store defined using a `State` object with properties of these types: + +- actions that update the state (defined using methods) +- derived state (defined using getters) +- basic state (all other properties) + +The store exposes each of the properties as an appropriate Jotai atom which you can then consume/use to interact with the state in the store. + +This way you can benefit from both the conciseness and simplicity of a central Zustand-ish store definition syntax, and the Jotai atom benefits such as cached, auto-updating derived values, and direct subscriptions that doesn't require selectors. + +## Definition + +```tsx +import { createAtomicStore } from 'jotai-zustand' + +const atomicStore = createAtomicStore({ + a: 1, + b: 2, + + // derived state defined using getters + get sum() { return this.a + this.b }, + get sum2() { return this.sum * 2 }, + + // actions return Partial or mutate state directly + adda(n: number) { return { a: this.a + n } }, + addb(n: number) { this.b += n }, +}); +// => { +// a: PrimitiveAtom +// b: PrimitiveAtom +// sum: Atom +// sum2: Atom +// adda: WritableAtom +// addb: WritableAtom +// }; +``` + +All method properties on the state object are considered to be actions, and they must either mutate the state in the `this` object directly, or return `Partial`, which will then be merged with the existing state. + +Derived state (aka computeds or computed values) are defined using getters, and are automatically updated when the state they depend on changes. Be careful not to create circular dependencies. + +## Usage + +The store can be consumed as a set of atoms: + +```tsx +import { useAtom, useAtomValue, useSetAtom } from 'jotai' + +export default function MyComponent() { + const a = useAtomValue(atomicStore.a) // number + const sum2x = useAtomValue(atomicStore.sum2) // number + const adda = useSetAtom(atomicStore.adda) // (n: number) => void + + return ( +
+
a: {a}
+
sum2x: {sum2x}
+ +
+ ) +} +``` + +Or through `useStore` and selectors, similarly to how Zustand works: + +```tsx +import { useStore } from 'jotai-zustand' +const sum = useStore(atomicStore, (state) => state.sum) +const state = useStore(atomicStore) +``` + +Using selectors is not quite as performant as using atoms. Each `useStore` call in each component instance will register a selector that is called on every store update. This can be expensive if you render many components that use selectors. + +Component instances that use atoms has no performance penalty unless the atom they depend on changes value. + +## Craz idea: Generalization + +The state definition object above could actually connect to and bridge to other state systems, e.g., + +```tsx +import { fromZustand, fromSignal, type State } from 'jotai-zustand' +const store = create({ + zustand: fromZustand(zustandStore), // composable + signal: fromSignal(signal$), // maybe auto-detect type + a: 1, + b: 2, + get sum() { return this.zustand.var + this.signal } +}) +// => State<{ +// zustand: State<...zustandStore>, +// signal: number, +// a: number, +// b: number, +// sum: readonly number +// }> +fromAtomic(store, { // extensible + get sum2() { return this.sum * 2 } +}) +// => State<{ +// zustand: State<...zustandStore>, +// signal: number, +// a: number, +// b: number, +// sum: number, +// sum2: number +// }> + +toSignals(store) +// => { +// zustand: { var: Signal }, +// a: Signal, +// b: Signal, +// signal: Signal, +// sum: Signal +// } +toAtoms(store) +// => { +// zustand: { var: atom<...> }, +// signal: atom, +// a: atom, +// b: atom, +// sum: atom +// } +``` + +## To do + +Must explore: + +- [ ] Best way to track dependencies and create atoms +- [ ] Naming :) + +Also likely explore: + +- [ ] Generalization to other state systems +- [ ] Zustand compatibility + - [ ] Consume store using selectors — ideate API (the above Zustand one looks good to me, but not clear how to deal with setting basic state) + - [ ] Also offer a setState / getState API + - [ ] Create atomic store from a Zustand store (and allow easy addition of derived state) +- [ ] Dealing with async (state, computeds, actions, selectors) +- [ ] Allow property setters in addition to property getters + +Perhaps out of scope: + +- [ ] Dealing with nested stores/state (I think this would be very useful) + +Out of scope: + +- [ ] Also allow using atoms within the store diff --git a/examples/01_typescript/package.json b/examples/01_typescript/package.json index 342e212..83c5526 100644 --- a/examples/01_typescript/package.json +++ b/examples/01_typescript/package.json @@ -1,5 +1,5 @@ { - "name": "example", + "name": "example-01", "version": "0.0.0", "private": true, "type": "module", diff --git a/examples/02_create/package.json b/examples/02_create/package.json index 342e212..db6c428 100644 --- a/examples/02_create/package.json +++ b/examples/02_create/package.json @@ -1,5 +1,5 @@ { - "name": "example", + "name": "example-02", "version": "0.0.0", "private": true, "type": "module", diff --git a/examples/03_atomic/index.html b/examples/03_atomic/index.html new file mode 100644 index 0000000..ec005a3 --- /dev/null +++ b/examples/03_atomic/index.html @@ -0,0 +1,9 @@ + + + example + + +
+ + + diff --git a/examples/03_atomic/package.json b/examples/03_atomic/package.json new file mode 100644 index 0000000..c32c74e --- /dev/null +++ b/examples/03_atomic/package.json @@ -0,0 +1,22 @@ +{ + "name": "example-03", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "jotai": "latest", + "jotai-zustand": "latest", + "react": "latest", + "react-dom": "latest", + "zustand": "latest" + }, + "devDependencies": { + "@types/react": "latest", + "@types/react-dom": "latest", + "typescript": "latest", + "vite": "latest" + }, + "scripts": { + "dev": "vite" + } +} diff --git a/examples/03_atomic/src/app.tsx b/examples/03_atomic/src/app.tsx new file mode 100644 index 0000000..6234184 --- /dev/null +++ b/examples/03_atomic/src/app.tsx @@ -0,0 +1,40 @@ +import { createAtomicStore } from '../../../src/index.js'; +import { useAtomValue, useSetAtom } from 'jotai/react'; + +const store = createAtomicStore({ + count: 0, + get half() { + return this.count / 2; + }, + get dbl() { + console.log('dbl - count=', this.count); + return this.half * 4; + }, + inc(n = 1) { + return { count: this.count + n }; + }, +}); + +const Counter = () => { + const count = useAtomValue(store.count); + const half = useAtomValue(store.half); + const dbl = useAtomValue(store.dbl); + const inc = useSetAtom(store.inc); + + return ( + <> +
count: {count}
+
half: {half}
+
dbl: {dbl}
+ + + ); +}; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/examples/03_atomic/src/main.tsx b/examples/03_atomic/src/main.tsx new file mode 100644 index 0000000..1a72c01 --- /dev/null +++ b/examples/03_atomic/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import App from './app'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/examples/03_atomic/tsconfig.json b/examples/03_atomic/tsconfig.json new file mode 100644 index 0000000..f9e0a7e --- /dev/null +++ b/examples/03_atomic/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es2018", + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "allowJs": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "jsx": "react-jsx" + } +} diff --git a/package.json b/package.json index 3df4a91..72e715b 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "test:types:examples": "tsc -p examples --noEmit", "test:spec": "vitest run", "examples:01_typescript": "DIR=01_typescript vite", - "examples:02_create": "DIR=02_create vite" + "examples:02_create": "DIR=02_create vite", + "examples:03_atomic": "DIR=03_atomic vite" }, "keywords": [ "jotai", diff --git a/src/atomicStore.ts b/src/atomicStore.ts new file mode 100644 index 0000000..c38f702 --- /dev/null +++ b/src/atomicStore.ts @@ -0,0 +1,267 @@ +import { atom } from 'jotai'; +import type { Atom, WritableAtom, PrimitiveAtom } from 'jotai'; + +/** + * Creates an atomic store that combines Zustand-like state definition with Jotai atoms. + * + * The state definition object passed to this function can consist of three kinds of properties: + * + * **Base State** + * - Regular properties (not functions or getters) + * - Stored in a single root atom for efficient updates + * - Becomes a `PrimitiveAtom` in the store + * + * **Derived State** + * - Defined using property getters + * - Auto-updates when dependencies change + * - Cached by Jotai to prevent unnecessary recomputation + * - Becomes a read-only `Atom` in the store + * + * **Actions** + * - Regular functions that can update state + * - Can return partial `Partial` updates or modify state via `this` + * - Becomes a `WritableAtom` in the store + * + * @returns An object where each property is converted to a Jotai atom: + * - Base state becomes a primitive atom: `PrimitiveAtom` + * - Derived state becomes a read-only atom: `Atom` + * - Actions become writable atoms: `WritableAtom` + * + * @example Basic store definition + * ```ts + * const store = createAtomicStore({ + * count: 0, + * get double() { return this.count * 2 }, + * increment(n = 1) { return { count: this.count + n } } + * }) + * ``` + * + * @example Usage with React hooks + * ```tsx + * function Counter() { + * // Read base or derived state with useAtomValue + * const count = useAtomValue(store.count) + * const double = useAtomValue(store.double) + * + * // Get action setter with useSetAtom + * const increment = useSetAtom(store.increment) + * + * return ( + *
+ *

Count: {count}

+ *

Double: {double}

+ * + * + *
+ * ) + * } + * ``` + * + * @example Direct usage with Jotai store + * ```ts + * const jotai = createStore() + * + * // Read values + * const count = jotai.get(store.count) + * const double = jotai.get(store.double) + * + * // Update base state + * jotai.set(store.count, 42) + * + * // Call actions + * jotai.set(store.increment) + * ``` + * + * @template State - Type of the state definition object + */ +export function createAtomicStore( + initial: AtomicState, +): AtomicStore { + const store = {} as AtomicStore; + const baseAtoms = new Map>(); + + // Create a single root atom for all base state values + const baseValues = {} as Record; + for (const [key, value] of Object.entries(initial)) { + const k = key as keyof State; + const desc = Object.getOwnPropertyDescriptor(initial, k); + if ( + typeof value !== 'function' && // Not an action + !desc?.get // Not derived state + ) + baseValues[k] = value; + } + const rootAtom = atom(baseValues); + + // Create atoms for each base state property + for (const key of Object.keys(baseValues)) { + const k = key as keyof State; + const baseAtom = atom( + (get) => get(rootAtom)[k], + (get, set, update: State[typeof k]) => { + const current = get(rootAtom); + set(rootAtom, { ...current, [k]: update }); + }, + ) as PrimitiveAtom; + baseAtoms.set(k, baseAtom); + store[k] = baseAtom as AtomicStore[typeof k]; + } + + // Create derived state atoms + for (const [key, desc] of Object.entries( + Object.getOwnPropertyDescriptors(initial), + )) { + if (!desc.get) continue; + + const k = key as keyof State; + store[k] = createDerivedAtom( + k, + desc.get!, + store, + baseAtoms, + initial, + ) as AtomicStore[typeof k]; + } + + // Create action atoms + for (const [key, value] of Object.entries(initial)) { + const k = key as keyof State; + const desc = Object.getOwnPropertyDescriptor(initial, k); + if ( + typeof value !== 'function' || // Not an action + desc?.get // Skip getters (derived state) + ) + continue; + + store[k] = createActionAtom( + k, + value, + store, + baseAtoms, + initial, + ) as AtomicStore[typeof k]; + } + + return store; +} + +/** + * Creates a derived state atom that automatically updates when its dependencies change. + * + * @template T - The type of the atomic state definition object. + * @param key - The key of the derived state property. + * @param getFn - The getter function for the derived state. + * @param store - The atomic store. + * @param baseAtoms - Map of base atoms. + * @param initial - The initial atomic state definition. + * @returns An atom representing the derived state. + */ +function createDerivedAtom( + key: keyof T, + getFn: () => any, + store: AtomicStore, + baseAtoms: Map>, + initial: AtomicState, +): Atom { + return atom((get) => { + const state = createStateGetter(get, store, baseAtoms, initial); + return getFn.call(state); + }); +} + +/** + * Creates an action atom that can update multiple base state values atomically. + * + * @template T - The type of the atomic state definition object. + * @param key - The key of the action property. + * @param actionFn - The function representing the action. + * @param store - The atomic store. + * @param baseAtoms - Map of base atoms. + * @param initial - The initial atomic state definition. + * @returns A writable atom representing the action. + */ +function createActionAtom( + key: keyof T, + actionFn: Function, + store: AtomicStore, + baseAtoms: Map>, + initial: AtomicState, +): WritableAtom { + type Args = T[typeof key] extends (...args: infer P) => any ? P : never; + + return atom(null, (get, set, ...args: Args) => { + const state = createStateGetter(get, store, baseAtoms, initial); + const result = actionFn.apply(state, args); + if (result) { + for (const [k, v] of Object.entries(result)) { + if (baseAtoms.has(k as keyof T)) set(baseAtoms.get(k as keyof T)!, v); + } + } + }) as unknown as WritableAtom; +} + +/** + * Creates an object with getters that access the latest atom values. + * Used to provide the `this` context in derived state and actions. + * + * @template T - The type of the atomic state definition object. + * @param get - The 'get' function provided by Jotai atoms. + * @param store - The atomic store. + * @param baseAtoms - Map of base atoms. + * @param initial - The initial atomic state definition. + * @returns An object with getters for each property. + */ +function createStateGetter( + get: any, + store: AtomicStore, + baseAtoms: Map>, + initial: AtomicState, +) { + const state = Object.create(null); + for (const propKey of Object.keys(initial)) { + const pk = propKey as keyof T; + Object.defineProperty(state, pk, { + get() { + if (baseAtoms.has(pk)) return get(baseAtoms.get(pk)!); + return get(store[pk] as Atom); + }, + enumerable: true, + }); + } + return state; +} + +/** + * Store definition type that allows base state, derived state (getters), + * and actions that return void or partial state updates. + * + * @template T - The base type of the store. + */ +type AtomicState = { + [K in keyof T]: T[K] extends (...args: infer Args) => any + ? (...args: Args) => ValidActionReturn + : T[K]; +}; + +/** + * Generated store type where each property becomes a Jotai atom: + * - Actions -> WritableAtom + * - Derived state -> Atom + * - Base state -> PrimitiveAtom + * + * @template T - The base type of the store. + */ +type AtomicStore = { + [K in keyof T]: T[K] extends (...args: any[]) => any + ? WritableAtom, void> + : T[K] extends { get: () => any } + ? Atom> + : PrimitiveAtom +} + +/** + * Valid return types for store actions + * + * @template T - The type of the atomic state definition object. + */ +type ValidActionReturn = void | Partial; diff --git a/src/index.ts b/src/index.ts index 36f55c2..514a181 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export { atomWithStore } from './atomWithStore.js'; export { atomWithActions } from './atomWithActions.js'; export { useSelector } from './useSelector.js'; export { create } from './create.js'; +export * from './atomicStore.js'; diff --git a/tests/03_atomicStore.spec.tsx b/tests/03_atomicStore.spec.tsx new file mode 100644 index 0000000..fe57ed8 --- /dev/null +++ b/tests/03_atomicStore.spec.tsx @@ -0,0 +1,421 @@ +import React from 'react'; +import { expect, test, describe } from 'vitest'; +import { atom, createStore } from 'jotai'; +import { render, fireEvent } from '@testing-library/react'; +import { useAtomValue, useSetAtom, Provider } from 'jotai'; +import { createAtomicStore } from '../src/index.js'; + +describe('AtomicStore', () => { + describe('Basic Functionality', () => { + test('basic value caching', () => { + const computeCount = { value: 0 }; + const store = createAtomicStore({ + base: 0, + get value() { + computeCount.value++; + return this.base * 2; + }, + }); + + const jotai = createStore(); + + // Initial read and reset counter + jotai.get(store.value); + computeCount.value = 0; + + // First read should use cached value from initial read + expect(jotai.get(store.value)).toBe(0); + expect(computeCount.value).toBe(0); // Should be 0 because value was cached + + // Update base value should trigger recomputation + jotai.set(store.base, 1); + expect(jotai.get(store.value)).toBe(2); + expect(computeCount.value).toBe(1); // Should compute once after base changed + + // Subsequent reads should use cached value + expect(jotai.get(store.value)).toBe(2); + expect(computeCount.value).toBe(1); // Should still be 1 (using cache) + }); + + test('independent chains', () => { + const computeCount = { a: 0, b: 0 }; + const store = createAtomicStore({ + valueA: 0, + valueB: 0, + get a() { + computeCount.a++; + return this.valueA * 2; + }, + get b() { + computeCount.b++; + return this.valueB * 2; + }, + }); + + const jotai = createStore(); + + // Initialize and reset counters + jotai.get(store.a); + jotai.get(store.b); + computeCount.a = 0; + computeCount.b = 0; + + // Update only A + jotai.set(store.valueA, 1); + expect(jotai.get(store.a)).toBe(2); + expect(jotai.get(store.b)).toBe(0); + expect(computeCount).toEqual({ a: 1, b: 0 }); + }); + + test('dependency chain', () => { + const computeCount = { double: 0, quad: 0 }; + const store = createAtomicStore({ + base: 0, + get double() { + computeCount.double++; + return this.base * 2; + }, + get quad() { + computeCount.quad++; + return this.double * 2; + }, + }); + + const jotai = createStore(); + + // Initialize and reset counters + jotai.get(store.quad); + computeCount.double = 0; + computeCount.quad = 0; + + // Update base + jotai.set(store.base, 1); + expect(jotai.get(store.quad)).toBe(4); + expect(computeCount).toEqual({ double: 1, quad: 1 }); + }); + + test('partial chain updates', () => { + const computeCount = { a: 0, b: 0, sum: 0 }; + const store = createAtomicStore({ + x: 0, + y: 0, + get a() { + computeCount.a++; + return this.x * 2; + }, + get b() { + computeCount.b++; + return this.y * 2; + }, + get sum() { + computeCount.sum++; + return this.a + this.b; + }, + }); + + const jotai = createStore(); + + // Initialize and reset computation counters + jotai.get(store.sum); + computeCount.a = 0; + computeCount.b = 0; + computeCount.sum = 0; + + // Update x only - this should only trigger recomputation of 'a' and 'sum' + // 'b' should not recompute since its dependency (y) hasn't changed + jotai.set(store.x, 1); + expect(jotai.get(store.sum)).toBe(2); + expect(computeCount).toEqual({ + a: 1, // Recomputed because x changed + b: 0, // Not recomputed because y didn't change + sum: 1, // Recomputed because 'a' changed + }); + }); + + test('multiple dependencies on same value', () => { + const computeCount = { a: 0, b: 0, sum: 0 }; + const store = createAtomicStore({ + x: 1, + get a() { + computeCount.a++; + return this.x * 2; + }, + get b() { + computeCount.b++; + return this.x * 3; + }, + get sum() { + computeCount.sum++; + return this.a + this.b + this.x; + }, + }); + + const jotai = createStore(); + + // Initialize by reading each value individually + const initialA = jotai.get(store.a); + const initialB = jotai.get(store.b); + const initialSum = jotai.get(store.sum); + expect(initialSum).toBe(6); // (1*2) + (1*3) + 1 + + // Reset counters + computeCount.a = 0; + computeCount.b = 0; + computeCount.sum = 0; + + // Read again to verify caching + expect(jotai.get(store.sum)).toBe(6); + expect(computeCount).toEqual({ a: 0, b: 0, sum: 0 }); + + // Update x + jotai.set(store.x, 2); + expect(jotai.get(store.sum)).toBe(12); // (2*2) + (2*3) + 2 + expect(computeCount).toEqual({ a: 1, b: 1, sum: 1 }); + }); + }); + + describe('Advanced Dependency Chains', () => { + test('deep dependency chain', () => { + const computeCount = { a: 0, b: 0, c: 0, d: 0 }; + const store = createAtomicStore({ + base: 0, + get a() { + computeCount.a++; + return this.base + 1; + }, + get b() { + computeCount.b++; + return this.a * 2; + }, + get c() { + computeCount.c++; + return this.b + this.a; + }, + get d() { + computeCount.d++; + return this.c * 2; + }, + }); + + const jotai = createStore(); + + // Initialize and reset + jotai.get(store.d); + computeCount.a = computeCount.b = computeCount.c = computeCount.d = 0; + + // Update base + jotai.set(store.base, 1); + expect(jotai.get(store.d)).toBe(12); // ((1 + 1) * 2 + (1 + 1)) * 2 + expect(computeCount).toEqual({ a: 1, b: 1, c: 1, d: 1 }); + }); + + test('deep dependency chain updates', () => { + const computeCount = { a: 0, b: 0, c: 0, d: 0 }; + const store = createAtomicStore({ + base: 1, + get a() { + computeCount.a++; + return this.base + 1; + }, + get b() { + computeCount.b++; + return this.a * 2; + }, + get c() { + computeCount.c++; + return this.b + this.a; + }, + get d() { + computeCount.d++; + return this.c * 2; + }, + }); + + const jotai = createStore(); + + // Initialize and reset counters + expect(jotai.get(store.d)).toBe(12); + computeCount.a = computeCount.b = computeCount.c = computeCount.d = 0; + + // Update base + jotai.set(store.base, 2); + expect(jotai.get(store.d)).toBe(18); + expect(computeCount).toEqual({ a: 1, b: 1, c: 1, d: 1 }); + }); + }); + + describe('Conditional Dependencies', () => { + test('conditional dependency access', () => { + const computeCount = { a: 0, b: 0 }; + const store = createAtomicStore({ + x: 0, + y: 0, + get a() { + computeCount.a++; + // Only access b when x > 0 + return this.x > 0 ? this.b : 0; + }, + get b() { + computeCount.b++; + return this.y * 2; + }, + }); + + const jotai = createStore(); + + // Initialize by reading both values + const initialA = jotai.get(store.a); + const initialB = jotai.get(store.b); + expect(initialA).toBe(0); + expect(initialB).toBe(0); + + // Reset counters + computeCount.a = 0; + computeCount.b = 0; + + // Update y while x is 0 + jotai.set(store.y, 1); + expect(jotai.get(store.a)).toBe(0); + expect(computeCount).toEqual({ a: 0, b: 0 }); + }); + }); + + describe('Error Handling', () => { + test('method error propagation', () => { + const store = createAtomicStore({ + value: 1, + faultyMethod() { + throw new Error('Intentional Error'); + }, + }); + + const jotai = createStore(); + + expect(() => jotai.set(store.faultyMethod)).toThrow('Intentional Error'); + expect(jotai.get(store.value)).toBe(1); // State should remain unchanged + }); + + test('cyclic dependency detection', () => { + expect(() => { + const store = createAtomicStore({ + get a() { + return this.b; + }, + get b() { + return this.a; + }, + }); + }).toThrowError( + /Cyclic dependency detected|Maximum call stack size exceeded/i, + ); + }); + }); + + describe('Miscellaneous', () => { + test('dynamic atom addition', () => { + const initialStore = { value: 1 }; + const store = createAtomicStore(initialStore) as any; + const jotai = createStore(); + + // Dynamically add a new base atom + const newBaseAtom = atom(2); + store.newValue = newBaseAtom; + + // Update and verify the new atom + jotai.set(store.newValue, 5); + expect(jotai.get(store.newValue)).toBe(5); + }); + + test('state serialization and restoration', () => { + const store = createAtomicStore({ count: 0 }); + const jotai = createStore(); + + jotai.set(store.count, 42); + + // Serialize state + const serializedState = JSON.stringify({ count: jotai.get(store.count) }); + + // Restore state in a new store + const newStore = createAtomicStore({ count: 0 }); + const newJotai = createStore(); + const restoredState = JSON.parse(serializedState); + newJotai.set(newStore.count, restoredState.count); + + expect(newJotai.get(newStore.count)).toBe(42); + }); + + test('atomicity of multi-value updates', () => { + const computeCount = { sum: 0 }; + const store = createAtomicStore({ + a: 1, + b: 2, + get sum() { + computeCount.sum++; + return this.a + this.b; + }, + updateValues(newA: number, newB: number) { + return { a: newA, b: newB }; + }, + }); + + const jotai = createStore(); + + // Initialize and reset compute count + jotai.get(store.sum); + computeCount.sum = 0; + + // Update both 'a' and 'b' atomically + jotai.set(store.updateValues, 3, 4); + expect(jotai.get(store.sum)).toBe(7); + expect(computeCount.sum).toBe(1); // Should only recompute once + }); + }); + + describe('React Integration', () => { + test('React component usage with atomicStore', () => { + // Define the store + const store = createAtomicStore({ + count: 0, + get doubleCount() { + return this.count * 2; + }, + increment() { + return { count: this.count + 1 }; + }, + }); + + // Define the component + const CounterComponent = () => { + const count = useAtomValue(store.count); + const doubleCount = useAtomValue(store.doubleCount); + const increment = useSetAtom(store.increment); + + return ( +
+

Count: {count}

+

Double Count: {doubleCount}

+ +
+ ); + }; + + // Render the component within Jotai's Provider + const { getByText } = render( + + + , + ); + + // Verify initial state + expect(getByText('Count: 0')).toBeInTheDocument(); + expect(getByText('Double Count: 0')).toBeInTheDocument(); + + // Trigger the action + fireEvent.click(getByText('Increment')); + + // Verify updated state + expect(getByText('Count: 1')).toBeInTheDocument(); + expect(getByText('Double Count: 2')).toBeInTheDocument(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 760ab5c..9e6655b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,10 @@ { "compilerOptions": { "strict": true, - "target": "es2018", + "target": "es2020", "esModuleInterop": true, "module": "nodenext", + // "moduleResolution": "nodenext", "skipLibCheck": true, "allowJs": true, "verbatimModuleSyntax": true,