From abf65467fd2f0ceac69e315c5167b92209067268 Mon Sep 17 00:00:00 2001 From: rtritto Date: Sat, 17 Aug 2024 13:47:12 +0200 Subject: [PATCH 1/3] Create solid-jotai-x as copy of jotai-x --- packages/solid-jotai-x/.npmignore | 3 + packages/solid-jotai-x/CHANGELOG.md | 54 ++ packages/solid-jotai-x/README.md | 253 ++++++ packages/solid-jotai-x/package.json | 60 ++ packages/solid-jotai-x/src/atomWithFn.ts | 32 + .../solid-jotai-x/src/createAtomProvider.tsx | 129 ++++ .../src/createAtomStore.spec.tsx | 726 ++++++++++++++++++ packages/solid-jotai-x/src/createAtomStore.ts | 340 ++++++++ .../solid-jotai-x/src/elementAtom.spec.tsx | 219 ++++++ packages/solid-jotai-x/src/index.ts | 8 + packages/solid-jotai-x/src/useHydrateStore.ts | 52 ++ packages/solid-jotai-x/tsconfig.json | 8 + 12 files changed, 1884 insertions(+) create mode 100644 packages/solid-jotai-x/.npmignore create mode 100644 packages/solid-jotai-x/CHANGELOG.md create mode 100644 packages/solid-jotai-x/README.md create mode 100644 packages/solid-jotai-x/package.json create mode 100644 packages/solid-jotai-x/src/atomWithFn.ts create mode 100644 packages/solid-jotai-x/src/createAtomProvider.tsx create mode 100644 packages/solid-jotai-x/src/createAtomStore.spec.tsx create mode 100644 packages/solid-jotai-x/src/createAtomStore.ts create mode 100644 packages/solid-jotai-x/src/elementAtom.spec.tsx create mode 100644 packages/solid-jotai-x/src/index.ts create mode 100644 packages/solid-jotai-x/src/useHydrateStore.ts create mode 100644 packages/solid-jotai-x/tsconfig.json diff --git a/packages/solid-jotai-x/.npmignore b/packages/solid-jotai-x/.npmignore new file mode 100644 index 0000000..7d3b305 --- /dev/null +++ b/packages/solid-jotai-x/.npmignore @@ -0,0 +1,3 @@ +__tests__ +__test-utils__ +__mocks__ diff --git a/packages/solid-jotai-x/CHANGELOG.md b/packages/solid-jotai-x/CHANGELOG.md new file mode 100644 index 0000000..750ff54 --- /dev/null +++ b/packages/solid-jotai-x/CHANGELOG.md @@ -0,0 +1,54 @@ +# jotai-x + +## 1.2.4 + +### Patch Changes + +- [`24a1de7`](https://github.com/udecode/jotai-x/commit/24a1de747cea2ecc89b3005877527a7805a0eb87) by [@zbeyens](https://github.com/zbeyens) – doc + +## 1.2.3 + +### Patch Changes + +- [#11](https://github.com/udecode/jotai-x/pull/11) by [@12joan](https://github.com/12joan) – Do not render jotai's Provider component as part of jotai-x's provider. Jotai's Provider is unnecessary and interferes with vanilla jotai atoms. + +- [#13](https://github.com/udecode/jotai-x/pull/13) by [@zbeyens](https://github.com/zbeyens) – use client in createAtomProvider + +## 1.2.2 + +### Patch Changes + +- [#8](https://github.com/udecode/jotai-x/pull/8) by [@zbeyens](https://github.com/zbeyens) – Fix React imports for SSR + +## 1.2.1 + +### Patch Changes + +- [#6](https://github.com/udecode/jotai-x/pull/6) by [@12joan](https://github.com/12joan) – Fix: Provider prop types expect atoms instead of values for stores created with custom atoms + +## 1.2.0 + +### Minor Changes + +- [#4](https://github.com/udecode/jotai-x/pull/4) by [@12joan](https://github.com/12joan) – Add `warnIfNoStore` option to `UseAtomOptions` + +## 1.1.0 + +### Minor Changes + +- [#2](https://github.com/udecode/jotai-x/pull/2) by [@12joan](https://github.com/12joan) – + - Atoms other than `atom` can now be passed in the `initialState` argument to `createAtomStore`. Primitive values use `atom` by default + - Added an `extend` option to `createAtomStore` that lets you add derived atoms to the store + - New accessors on `UseStoreApi` + - `useMyStore().store()` returns the `JotaiStore` for the current context, or undefined if no store exists + - `useMyStore().{get,set,use}.atom(someAtom)` accesses `someAtom` through the store + - Types: remove exports for some internal types + - `GetRecord` + - `SetRecord` + - `UseRecord` + +## 1.0.1 + +### Patch Changes + +- [`099d310`](https://github.com/udecode/jotai-x/commit/099d310cdec35767aeaa2616634cb2502ccbc5e7) by [@zbeyens](https://github.com/zbeyens) – Fix: add React as peer dependency. diff --git a/packages/solid-jotai-x/README.md b/packages/solid-jotai-x/README.md new file mode 100644 index 0000000..9484d89 --- /dev/null +++ b/packages/solid-jotai-x/README.md @@ -0,0 +1,253 @@ +# JotaiX + +JotaiX is a custom extension of [Jotai](https://github.com/pmndrs/jotai), a primitive and flexible state management library for React. Jotai offers a +minimalistic API to manage global, derived, or async states in React, solving common issues such as unnecessary +re-renders or complex context management. JotaiX builds upon this foundation, providing enhanced utilities and patterns +for more efficient and streamlined state management in larger and more complex applications. + +`jotai-x`, built on top of `jotai`, is providing a powerful store factory +which solves these challenges, so you can focus on your app. + +```bash +yarn add jotai jotai-x +``` + +For further details and API documentation, visit [jotai-x.udecode.dev](https://jotai-x.udecode.dev). + +## **Why Choose `jotai-x`?** + +- Reduces boilerplate: Simplifies state management with concise and powerful utilities. +- Enhanced modular state management: Offers advanced features like atom stores, hydration utilities, and more. +- Improved developer experience: Strong TypeScript support ensures type safety and better developer tooling. +- Seamless integration with Jotai: Builds on top of Jotai's API, making it easy for existing Jotai users to adopt. + +## **Core Features** + +### **Creating a Store** + +JotaiX allows for the creation of structured stores with ease, integrating seamlessly with Jotai's atom concept. + +```tsx +import { createAtomStore } from 'jotai-x'; + +// Notice how it uses the name of the store in the returned object. +export const { useElementStore, ElementProvider } = createAtomStore({ + element: null +}, { + name: 'element' +}); +``` + +The **`createAtomStore`** function simplifies the process of creating and managing atom-based states. + +#### Function Signature + +```tsx +createAtomStore(initialState: T, options?: CreateAtomStoreOptions): AtomStoreApi; +``` + +- **`initialState`**: This is an object representing the initial state of your store. Each key-value pair in this object is used to create an individual atom. This is required even if you want to set the initial value from the provider, otherwise the atom would not be created. +- **`options`**: Optional. This parameter allows you to pass additional configuration options for the store creation. + +#### Options + +The **`options`** object can include several properties to customize the behavior of your store: + +- **`name`**: A string representing the name of the store, which can be helpful for debugging or when working with multiple stores. +- **`delay`**: If you need to introduce a delay in state updates, you can specify it here. Optional. +- **`effect`**: A React component that can be used to run effects inside the provider. Optional. +- **`extend`**: Extend the store with derived atoms based on the store state. Optional. + +#### Return Value + +The **`createAtomStore`** function returns an object (**`AtomStoreApi`**) containing the following properties and methods for interacting with the store: + +- **`useStore`**: + - A function that returns the following objects: **`get`**, **`set`**, **`use`** and **`store`**, where values are hooks for each state defined in the store. + - **`get`**: Hooks for accessing a state within a component, ensuring re-rendering when the state changes. See [useAtomValue](https://jotai.org/docs/core/use-atom#useatomvalue). + - **`set`**: Hooks for setting a state within a component. See [useSetAtom](https://jotai.org/docs/core/use-atom#usesetatom). + - **`use`**: Hooks for accessing and setting a state within a component, ensuring re-rendering when the state changes. See [useAtom](https://jotai.org/docs/core/use-atom). + - **`store`**: A hook to access the [JotaiStore](https://jotai.org/docs/core/store) for the current context. + - Example: `const [element, setElement] = useElementStore().use.element()` +- **`Provider`**: + - The API includes dynamically generated provider components for each defined store. This allows scoped state management within your application. More information in the next section. +- **`Store`**: + - **`atom`**: Access the atoms used by the store, including derived atoms defined using `extend`. See [atom](https://jotai.org/docs/core/atom). + +### **Provider-Based Store Hydration and Synchronization** + +**`createAtomStore`** generates a provider component (`Provider`) for a Jotai store. This provider not only supplies the store to its child components but also handles hydrating and syncing the store's state. Here's how it works: + +- **Hydration**: Hydrates atoms with initial values. It's particularly useful for SSR, ensuring that the client-side state aligns with what was rendered on the server. Use `initialValues` prop. +- **Synchronization**: Updates atoms with new values as external changes occur, maintaining consistency across the application. Use `` props: there is one for each state defined in the store. + +### Scoped Providers and Context Management + +JotaiX creates scoped providers, enabling more granular control over different segments of state within your application. `createAtomStore` sets up a context for each store, which can be scoped using the **`scope`** prop. This is particularly beneficial in complex applications where nested providers are needed. + +### Derived Atoms + +There are two ways of creating derived atoms from your JotaiX store. + +#### Derived Atoms Using `extend` + +Atoms defined using the `extend` option are made available in the same places as other values in the store. + +```ts +const { useUserStore } = createAtomStore({ + username: 'Alice', +}, { + name: 'user', + extend: (atoms) => ({ + intro: atom((get) => `My name is ${get(atoms.username)}`), + }), +}); + +const intro = useAppStore().get.intro(); +``` + +#### Externally Defined Derived Atoms + +Derived atoms can also be defined externally by accessing the store's atoms through the `Store` API. Externally defined atoms can be accessed through the store using the special `useStore().{get,set,use}.atom` hooks. + +```ts +const { userStore, useUserStore } = createAtomStore({ + username: 'Alice', +}, { name: 'user' }); + +const introAtom = atom((get) => `My name is ${get(userStore.atom.username)}`); +const intro = useUserStore().get.atom(introAtom); +``` + +### Example Usage + +#### 1. Create a store + +```tsx +import { createAtomStore } from 'jotai-x'; + +export type AppStore = { + name: string; + onUpdateName: (name: string) => void; +}; + +const initialState: Nullable = { + name: null, + onUpdateName: null, +}; + +export const { useAppStore, AppProvider } = createAtomStore( + initialState as AppStore, + { name: 'app' } +); +``` + +#### 2. Use the store in a component + +```tsx +// ... + +const App = () => { + return ( + console.log(name) + }} + // Either here or in initialValues + name="John Doe" + > + + + ); +}; + +const Component = () => { + const [name, setName] = useAppStore().use.name(); + const onUpdateName = useAppStore().get.onUpdateName(); + + return ( +
+ setName(e.target.value)} /> + +
+ ); +}; +``` + +#### Scoped Providers + +```tsx +const App = () => { + return ( + // Parent scope + console.log("Parent:", name) + }} + name="Parent User" + > +
+

Parent Component

+ + {/* Child scope */} + console.log("Child:", name) + }} + name="Child User" + > +
+

Child Component

+ +
+
+
+
+ ); +}; + +// Accessing state from the specified scope. +const Component = () => { + // Here, we get the state from the parent scope + const [name, setName] = useAppStore('parent').use.name(); + // Here, we get the state from the closest scope (default) + const onUpdateName = useAppStore().get.onUpdateName(); + + return ( +
+ setName(e.target.value)} /> + +
+ ); +}; +``` + +## Contributing + +### Ideas and discussions + +[Discussions](https://github.com/udecode/jotai-x/discussions) is the best +place for bringing opinions and contributions. Letting us know if we're +going in the right or wrong direction is great feedback and will be much +appreciated! + +#### [Become a Sponsor!](https://github.com/sponsors/zbeyens) + +### Contributors + +🌟 Stars and 📥 Pull requests are welcome! Don't hesitate to **share +your feedback** here. Read our +[contributing guide](https://github.com/udecode/jotai-x/blob/main/CONTRIBUTING.md) +to get started. + +

+ + Deploys by Netlify + +

+ +## License + +[MIT](https://github.com/udecode/jotai-x/blob/main/LICENSE) diff --git a/packages/solid-jotai-x/package.json b/packages/solid-jotai-x/package.json new file mode 100644 index 0000000..735c8a0 --- /dev/null +++ b/packages/solid-jotai-x/package.json @@ -0,0 +1,60 @@ +{ + "name": "jotai-x", + "version": "1.2.4", + "description": "Jotai store factory for a best-in-class developer experience.", + "license": "MIT", + "homepage": "https://jotai-x.udecode.dev/", + "repository": { + "type": "git", + "url": "https://github.com/udecode/jotai-x.git", + "directory": "packages/jotai-x" + }, + "bugs": { + "url": "https://github.com/udecode/jotai-x/issues" + }, + "sideEffects": false, + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "module": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "yarn p:build", + "build:watch": "yarn p:build:watch", + "brl": "yarn p:brl", + "clean": "yarn p:clean", + "lint": "yarn p:lint", + "lint:fix": "yarn p:lint:fix", + "test": "yarn p:test", + "test:watch": "yarn p:test:watch", + "typecheck": "yarn p:typecheck" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "jotai": ">=2.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + }, + "keywords": [ + "jotai" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/solid-jotai-x/src/atomWithFn.ts b/packages/solid-jotai-x/src/atomWithFn.ts new file mode 100644 index 0000000..5c759a1 --- /dev/null +++ b/packages/solid-jotai-x/src/atomWithFn.ts @@ -0,0 +1,32 @@ +import { atom } from 'jotai'; + +import type { WritableAtom } from 'jotai/vanilla'; + +type WrapFn = T extends (...args: infer _A) => infer _R ? { __fn: T } : T; + +const wrapFn = (fnOrValue: T): WrapFn => + (typeof fnOrValue === 'function' ? { __fn: fnOrValue } : fnOrValue) as any; + +type UnwrapFn = T extends { __fn: infer U } ? U : T; + +const unwrapFn = (wrappedFnOrValue: T): UnwrapFn => + (wrappedFnOrValue && + typeof wrappedFnOrValue === 'object' && + '__fn' in wrappedFnOrValue + ? wrappedFnOrValue.__fn + : wrappedFnOrValue) as any; + +/** + * Jotai atoms don't allow functions as values by default. This function is a + * drop-in replacement for `atom` that wraps functions in an object while + * leaving non-functions unchanged. The wrapper object should be completely + * invisible to consumers of the atom. + */ +export const atomWithFn = (initialValue: T): WritableAtom => { + const baseAtom = atom(wrapFn(initialValue)); + + return atom( + (get) => unwrapFn(get(baseAtom)) as T, + (_get, set, value) => set(baseAtom, wrapFn(value)) + ); +}; diff --git a/packages/solid-jotai-x/src/createAtomProvider.tsx b/packages/solid-jotai-x/src/createAtomProvider.tsx new file mode 100644 index 0000000..20b8e8a --- /dev/null +++ b/packages/solid-jotai-x/src/createAtomProvider.tsx @@ -0,0 +1,129 @@ +'use client'; + +import React from 'react'; +import { createStore } from 'jotai/vanilla'; + +import { JotaiStore, SimpleWritableAtomRecord } from './createAtomStore'; +import { useHydrateStore, useSyncStore } from './useHydrateStore'; + +const getFullyQualifiedScope = (storeName: string, scope: string) => { + return `${storeName}:${scope}`; +}; + +/** + * Context mapping store name and scope to store. The 'provider' scope is used + * to reference any provider belonging to the store, regardless of scope. + */ +const PROVIDER_SCOPE = 'provider'; +const AtomStoreContext = React.createContext>( + new Map() +); + +/** + * Tries to find a store in each of the following places, in order: + * 1. The store context, matching the store name and scope + * 2. The store context, matching the store name and 'provider' scope + * 3. Otherwise, return undefined + */ +export const useAtomStore = ( + storeName: string, + scope: string = PROVIDER_SCOPE, + warnIfUndefined: boolean = true +): JotaiStore | undefined => { + const storeContext = React.useContext(AtomStoreContext); + const store = + storeContext.get(getFullyQualifiedScope(storeName, scope)) ?? + storeContext.get(getFullyQualifiedScope(storeName, PROVIDER_SCOPE)); + + if (!store && warnIfUndefined) { + console.warn( + `Tried to access jotai store '${storeName}' outside of a matching provider.` + ); + } + + return store; +}; + +export type ProviderProps = Partial & { + store?: JotaiStore; + scope?: string; + initialValues?: Partial; + resetKey?: any; + children: React.ReactNode; +}; + +export const HydrateAtoms = ({ + initialValues, + children, + store, + atoms, + ...props +}: Omit, 'scope'> & { + atoms: SimpleWritableAtomRecord; +}) => { + useHydrateStore(atoms, { ...initialValues, ...props } as any, { + store, + }); + useSyncStore(atoms, props as any, { + store, + }); + + return <>{children}; +}; + +/** + * Creates a generic provider for a jotai store. + * - `initialValues`: Initial values for the store. + * - `props`: Dynamic values for the store. + */ +export const createAtomProvider = ( + storeScope: N, + atoms: SimpleWritableAtomRecord, + options: { effect?: React.FC } = {} +) => { + const Effect = options.effect; + + // eslint-disable-next-line react/display-name + return ({ store, scope, children, resetKey, ...props }: ProviderProps) => { + const [storeState, setStoreState] = + React.useState(createStore()); + + React.useEffect(() => { + if (resetKey) { + setStoreState(createStore()); + } + }, [resetKey]); + + const previousStoreContext = React.useContext(AtomStoreContext); + + const storeContext = React.useMemo(() => { + const newStoreContext = new Map(previousStoreContext); + + if (scope) { + // Make the store findable by its fully qualified scope + newStoreContext.set( + getFullyQualifiedScope(storeScope, scope), + storeState + ); + } + + // Make the store findable by its store name alone + newStoreContext.set( + getFullyQualifiedScope(storeScope, PROVIDER_SCOPE), + storeState + ); + + return newStoreContext; + }, [previousStoreContext, scope, storeState]); + + return ( + + + {!!Effect && } + + {children} + + + ); + }; +}; diff --git a/packages/solid-jotai-x/src/createAtomStore.spec.tsx b/packages/solid-jotai-x/src/createAtomStore.spec.tsx new file mode 100644 index 0000000..56465c3 --- /dev/null +++ b/packages/solid-jotai-x/src/createAtomStore.spec.tsx @@ -0,0 +1,726 @@ +import '@testing-library/jest-dom'; + +import React from 'react'; +import { act, queryByText, render, renderHook } from '@testing-library/react'; +import { atom, PrimitiveAtom, useAtomValue } from 'jotai'; +import { splitAtom } from 'jotai/utils'; + +import { createAtomStore } from './createAtomStore'; + +describe('createAtomStore', () => { + describe('single provider', () => { + type MyTestStoreValue = { + name: string; + age: number; + becomeFriends: () => void; + }; + + const INITIAL_NAME = 'John'; + const INITIAL_AGE = 42; + + const initialTestStoreValue: MyTestStoreValue = { + name: INITIAL_NAME, + age: INITIAL_AGE, + becomeFriends: () => {}, + }; + + const { myTestStoreStore, useMyTestStoreStore, MyTestStoreProvider } = + createAtomStore(initialTestStoreValue, { name: 'myTestStore' as const }); + + const ReadOnlyConsumer = () => { + const name = useMyTestStoreStore().get.name(); + const age = useMyTestStoreStore().get.age(); + + return ( +
+ {name} + {age} +
+ ); + }; + + const WRITE_ONLY_CONSUMER_AGE = 99; + + const WriteOnlyConsumer = () => { + const setAge = useMyTestStoreStore().set.age(); + + return ( + + ); + }; + + const MUTABLE_PROVIDER_INITIAL_AGE = 19; + const MUTABLE_PROVIDER_NEW_AGE = 20; + + const MutableProvider = ({ children }: { children: React.ReactNode }) => { + const [age, setAge] = React.useState(MUTABLE_PROVIDER_INITIAL_AGE); + + return ( + <> + {children} + + + + ); + }; + + const BecomeFriendsProvider = ({ + children, + }: { + children: React.ReactNode; + }) => { + const [becameFriends, setBecameFriends] = React.useState(false); + + return ( + <> + setBecameFriends(true)}> + {children} + + +
becameFriends: {becameFriends.toString()}
+ + ); + }; + + const BecomeFriendsGetter = () => { + // Make sure both of these are actual functions, not wrapped functions + const becomeFriends1 = useMyTestStoreStore().get.becomeFriends(); + const becomeFriends2 = useMyTestStoreStore().get.atom( + myTestStoreStore.atom.becomeFriends + ); + + return ( + + ); + }; + + const BecomeFriendsSetter = () => { + const setBecomeFriends = useMyTestStoreStore().set.becomeFriends(); + const [becameFriends, setBecameFriends] = React.useState(false); + + return ( + <> + + +
setterBecameFriends: {becameFriends.toString()}
+ + ); + }; + + const BecomeFriendsUser = () => { + const [, setBecomeFriends] = useMyTestStoreStore().use.becomeFriends(); + const [becameFriends, setBecameFriends] = React.useState(false); + + return ( + <> + + +
userBecameFriends: {becameFriends.toString()}
+ + ); + }; + + beforeEach(() => { + renderHook(() => useMyTestStoreStore().set.name()(INITIAL_NAME)); + renderHook(() => useMyTestStoreStore().set.age()(INITIAL_AGE)); + }); + + it('passes default values from provider to consumer', () => { + const { getByText } = render( + + + + ); + + expect(getByText(INITIAL_NAME)).toBeInTheDocument(); + expect(getByText(INITIAL_AGE)).toBeInTheDocument(); + }); + + it('passes non-default values from provider to consumer', () => { + const { getByText } = render( + + + + ); + + expect(getByText('Jane')).toBeInTheDocument(); + expect(getByText('94')).toBeInTheDocument(); + }); + + it('propagates updates from provider to consumer', () => { + const { getByText } = render( + + + + ); + + expect(getByText(INITIAL_NAME)).toBeInTheDocument(); + expect(getByText(MUTABLE_PROVIDER_INITIAL_AGE)).toBeInTheDocument(); + + act(() => getByText('providerSetAge').click()); + + expect(getByText(INITIAL_NAME)).toBeInTheDocument(); + expect(getByText(MUTABLE_PROVIDER_NEW_AGE)).toBeInTheDocument(); + }); + + it('propagates updates between consumers', () => { + const { getByText } = render( + + + + + ); + + expect(getByText(INITIAL_NAME)).toBeInTheDocument(); + expect(getByText(INITIAL_AGE)).toBeInTheDocument(); + + act(() => getByText('consumerSetAge').click()); + + expect(getByText(INITIAL_NAME)).toBeInTheDocument(); + expect(getByText(WRITE_ONLY_CONSUMER_AGE)).toBeInTheDocument(); + }); + + it('prefers the most recent update', () => { + const { getByText } = render( + + + + + ); + + expect(getByText(INITIAL_NAME)).toBeInTheDocument(); + expect(getByText(MUTABLE_PROVIDER_INITIAL_AGE)).toBeInTheDocument(); + + act(() => getByText('consumerSetAge').click()); + + expect(getByText(INITIAL_NAME)).toBeInTheDocument(); + expect(getByText(WRITE_ONLY_CONSUMER_AGE)).toBeInTheDocument(); + + act(() => getByText('providerSetAge').click()); + + expect(getByText(INITIAL_NAME)).toBeInTheDocument(); + expect(getByText(MUTABLE_PROVIDER_NEW_AGE)).toBeInTheDocument(); + + act(() => getByText('consumerSetAge').click()); + + expect(getByText(INITIAL_NAME)).toBeInTheDocument(); + expect(getByText(WRITE_ONLY_CONSUMER_AGE)).toBeInTheDocument(); + }); + + it('provides and gets functions', () => { + const { getByText } = render( + + + + ); + + expect(getByText('becameFriends: false')).toBeInTheDocument(); + act(() => getByText('Become Friends').click()); + expect(getByText('becameFriends: true')).toBeInTheDocument(); + }); + + it('sets functions', () => { + const { getByText } = render( + + + + + ); + + act(() => getByText('Change Callback').click()); + expect(getByText('setterBecameFriends: false')).toBeInTheDocument(); + act(() => getByText('Become Friends').click()); + expect(getByText('setterBecameFriends: true')).toBeInTheDocument(); + }); + + it('uses functions', () => { + const { getByText } = render( + + + + + ); + + act(() => getByText('Change Callback').click()); + expect(getByText('userBecameFriends: false')).toBeInTheDocument(); + act(() => getByText('Become Friends').click()); + expect(getByText('userBecameFriends: true')).toBeInTheDocument(); + }); + }); + + describe('scoped providers', () => { + type MyScopedTestStoreValue = { age: number | null }; + + const initialScopedTestStoreValue: MyScopedTestStoreValue = { + age: null, + }; + + const { useMyScopedTestStoreStore, MyScopedTestStoreProvider } = + createAtomStore(initialScopedTestStoreValue, { + name: 'myScopedTestStore' as const, + }); + + const ReadOnlyConsumer = ({ scope }: { scope: string }) => { + const age = useMyScopedTestStoreStore().get.age({ scope }); + + return ( +
+ {JSON.stringify(age)} +
+ ); + }; + + const ReadOnlyConsumerWithScopeShorthand = ({ + scope, + }: { + scope: string; + }) => { + const age = useMyScopedTestStoreStore(scope).get.age(); + + return ( +
+ {JSON.stringify(age)} +
+ ); + }; + + it('returns value of first ancestor when scope matches no provider', () => { + const { getByText } = render( + + + + + + ); + + expect(getByText('2')).toBeInTheDocument(); + }); + + it('returns value of first matching ancestor provider', () => { + const { getByText } = render( + + + + + + + + + + + + + + ); + + expect(getByText('4')).toBeInTheDocument(); + }); + + it('allows shorthand to specify scope', () => { + const { getByText } = render( + + + + + + + + + + + + + + ); + + expect(getByText('4')).toBeInTheDocument(); + }); + }); + + describe('multiple unrelated stores', () => { + type MyFirstTestStoreValue = { name: string }; + type MySecondTestStoreValue = { age: number }; + + const initialFirstTestStoreValue: MyFirstTestStoreValue = { + name: 'My name', + }; + + const initialSecondTestStoreValue: MySecondTestStoreValue = { + age: 72, + }; + + const { useMyFirstTestStoreStore, MyFirstTestStoreProvider } = + createAtomStore(initialFirstTestStoreValue, { + name: 'myFirstTestStore' as const, + }); + + const { useMySecondTestStoreStore, MySecondTestStoreProvider } = + createAtomStore(initialSecondTestStoreValue, { + name: 'mySecondTestStore' as const, + }); + + const FirstReadOnlyConsumer = () => { + const name = useMyFirstTestStoreStore().get.name(); + + return ( +
+ {name} +
+ ); + }; + + const SecondReadOnlyConsumer = () => { + const age = useMySecondTestStoreStore().get.age(); + + return ( +
+ {age} +
+ ); + }; + + it('returns the value for the correct store', () => { + const { getByText } = render( + + + + + + + ); + + expect(getByText('Jane')).toBeInTheDocument(); + expect(getByText('98')).toBeInTheDocument(); + }); + }); + + describe('extended stores', () => { + type User = { + name: string; + age: number; + }; + + const initialUser: User = { + name: 'Jane', + age: 98, + }; + + const { userStore, useUserStore, UserProvider } = createAtomStore( + initialUser, + { + name: 'user' as const, + extend: ({ name, age }) => ({ + bio: atom((get) => `${get(name)} is ${get(age)} years old`), + }), + } + ); + + const ReadOnlyConsumer = () => { + const bio = useUserStore().get.bio(); + + return
{bio}
; + }; + + it('includes extended atom in store object', () => { + const { result } = renderHook(() => useAtomValue(userStore.atom.bio)); + expect(result.current).toBe('Jane is 98 years old'); + }); + + it('includes extended atom in get hooks', () => { + const { result } = renderHook(() => useUserStore().get.bio()); + expect(result.current).toBe('Jane is 98 years old'); + }); + + it('does not include read-only extended atom in set hooks', () => { + const { result } = renderHook(() => Object.keys(useUserStore().set)); + expect(result.current).not.toContain('bio'); + }); + + it('does not include read-only extended atom in use hooks', () => { + const { result } = renderHook(() => Object.keys(useUserStore().use)); + expect(result.current).not.toContain('bio'); + }); + + it('computes extended atom based on current state', () => { + const { getByText } = render( + + + + ); + + expect(getByText('John is 42 years old')).toBeInTheDocument(); + }); + }); + + describe('passing atoms as part of initial state', () => { + type CustomAtom = PrimitiveAtom & { + isCustomAtom: true; + }; + + const createCustomAtom = (value: T): CustomAtom => ({ + ...atom(value), + isCustomAtom: true, + }); + + const { customStore, useCustomStore, CustomProvider } = createAtomStore( + { + x: createCustomAtom(1), + }, + { + name: 'custom' as const, + } + ); + + it('uses passed atom', () => { + const myAtom = customStore.atom.x as CustomAtom; + expect(myAtom.isCustomAtom).toBe(true); + }); + + it('accepts initial values', () => { + const { result } = renderHook(() => useCustomStore().get.x(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toBe(2); + }); + }); + + describe('arbitrary atom accessors', () => { + type User = { + name: string; + }; + + const initialUser: User = { + name: 'Jane', + }; + + const { userStore, useUserStore, UserProvider } = createAtomStore( + initialUser, + { + name: 'user' as const, + } + ); + + const derivedAtom = atom((get) => `My name is ${get(userStore.atom.name)}`); + + const DerivedAtomConsumer = () => { + const message = useUserStore().get.atom(derivedAtom); + + return
{message}
; + }; + + it('accesses arbitrary atom within store', () => { + const { getByText } = render( + + + + ); + + expect(getByText('My name is John')).toBeInTheDocument(); + }); + }); + + describe('splitAtoms using todoStore.atom.items', () => { + const initialState = { + items: [] as { + task: string; + done: boolean; + }[], + }; + + const { todoStore, useTodoStore, TodoProvider } = createAtomStore( + initialState, + { + name: 'todo' as const, + } + ); + + const todoAtomsAtom = splitAtom(todoStore.atom.items); + + type TodoType = (typeof initialState)['items'][number]; + + const TodoItem = ({ + todoAtom, + remove, + }: { + todoAtom: PrimitiveAtom; + remove: () => void; + }) => { + const [todo, setTodo] = useTodoStore().use.atom(todoAtom); + + return ( +
+ + { + setTodo((oldValue) => ({ ...oldValue, done: !oldValue.done })); + }} + /> + {/* eslint-disable-next-line react/button-has-type */} + +
+ ); + }; + + const TodoList = () => { + const [todoAtoms, dispatch] = useTodoStore().use.atom(todoAtomsAtom); + return ( +
    + {todoAtoms.map((todoAtom) => ( + dispatch({ type: 'remove', atom: todoAtom })} + /> + ))} +
+ ); + }; + + it('should work', () => { + const { getByText, container } = render( + + + + ); + + expect(getByText('help the town')).toBeInTheDocument(); + expect(getByText('feed the dragon')).toBeInTheDocument(); + + act(() => getByText('remove help the town').click()); + + expect(queryByText(container, 'help the town')).not.toBeInTheDocument(); + expect(getByText('feed the dragon')).toBeInTheDocument(); + }); + }); + + describe('splitAtoms using extend', () => { + const initialState = { + items: [] as { + task: string; + done: boolean; + }[], + }; + + const { useTodoStore, TodoProvider } = createAtomStore(initialState, { + name: 'todo' as const, + extend: ({ items }) => ({ + itemAtoms: splitAtom(items), + }), + }); + + type TodoType = (typeof initialState)['items'][number]; + + const TodoItem = ({ + todoAtom, + remove, + }: { + todoAtom: PrimitiveAtom; + remove: () => void; + }) => { + const [todo, setTodo] = useTodoStore().use.atom(todoAtom); + + return ( +
+ + { + setTodo((oldValue) => ({ ...oldValue, done: !oldValue.done })); + }} + /> + {/* eslint-disable-next-line react/button-has-type */} + +
+ ); + }; + + const TodoList = () => { + const [todoAtoms, dispatch] = useTodoStore().use.itemAtoms(); + + return ( +
    + {todoAtoms.map((todoAtom) => ( + dispatch({ type: 'remove', atom: todoAtom })} + /> + ))} +
+ ); + }; + + it('should work', () => { + const { getByText, container } = render( + + + + ); + + expect(getByText('help the town')).toBeInTheDocument(); + expect(getByText('feed the dragon')).toBeInTheDocument(); + + act(() => getByText('remove help the town').click()); + + expect(queryByText(container, 'help the town')).not.toBeInTheDocument(); + expect(getByText('feed the dragon')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/solid-jotai-x/src/createAtomStore.ts b/packages/solid-jotai-x/src/createAtomStore.ts new file mode 100644 index 0000000..c52c764 --- /dev/null +++ b/packages/solid-jotai-x/src/createAtomStore.ts @@ -0,0 +1,340 @@ +import React from 'react'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; + +import { atomWithFn } from './atomWithFn'; +import { createAtomProvider, useAtomStore } from './createAtomProvider'; + +import type { ProviderProps } from './createAtomProvider'; +import type { Atom, createStore, WritableAtom } from 'jotai/vanilla'; + +export type JotaiStore = ReturnType; + +export type UseAtomOptions = { + scope?: string; + store?: JotaiStore; + delay?: number; + warnIfNoStore?: boolean; +}; + +type UseAtomOptionsOrScope = UseAtomOptions | string; + +type GetRecord = { + [K in keyof O]: O[K] extends Atom + ? (options?: UseAtomOptionsOrScope) => V + : never; +}; + +type SetRecord = { + [K in keyof O]: O[K] extends WritableAtom + ? (options?: UseAtomOptionsOrScope) => (...args: A) => R + : never; +}; + +type UseRecord = { + [K in keyof O]: O[K] extends WritableAtom + ? (options?: UseAtomOptionsOrScope) => [V, (...args: A) => R] + : never; +}; + +type StoreAtomsWithoutExtend = { + [K in keyof T]: T[K] extends Atom ? T[K] : SimpleWritableAtom; +}; + +type ValueTypesForAtoms = { + [K in keyof T]: T[K] extends Atom ? V : never; +}; + +type StoreInitialValues = ValueTypesForAtoms>; + +type StoreAtoms = StoreAtomsWithoutExtend & E; + +type FilterWritableAtoms = { + [K in keyof T]-?: T[K] extends WritableAtom ? T[K] : never; +}; + +type WritableStoreAtoms = FilterWritableAtoms>; + +export type SimpleWritableAtom = WritableAtom; + +export type SimpleWritableAtomRecord = { + [K in keyof T]: SimpleWritableAtom; +}; + +export type AtomRecord = { + [K in keyof O]: Atom; +}; + +type UseNameStore = `use${Capitalize}Store`; +type NameStore = N extends '' ? 'store' : `${N}Store`; +type NameProvider = `${Capitalize}Provider`; +export type UseHydrateAtoms = ( + initialValues: Partial>, + options?: Parameters[1] +) => void; +export type UseSyncAtoms = ( + values: Partial>, + options?: { + store?: JotaiStore; + } +) => void; + +export type StoreApi< + T extends object, + E extends AtomRecord, + N extends string = '', +> = { + atom: StoreAtoms; + name: N; +}; + +type GetAtomFn = (atom: Atom, options?: UseAtomOptionsOrScope) => V; + +type SetAtomFn = ( + atom: WritableAtom, + options?: UseAtomOptionsOrScope +) => (...args: A) => R; + +type UseAtomFn = ( + atom: WritableAtom, + options?: UseAtomOptionsOrScope +) => [V, (...args: A) => R]; + +export type UseStoreApi = (options?: UseAtomOptionsOrScope) => { + get: GetRecord> & { atom: GetAtomFn }; + set: SetRecord> & { atom: SetAtomFn }; + use: UseRecord> & { atom: UseAtomFn }; + store: (options?: UseAtomOptionsOrScope) => JotaiStore | undefined; +}; + +export type AtomStoreApi< + T extends object, + E extends AtomRecord, + N extends string = '', +> = { + name: N; +} & { + [key in keyof Record, object>]: React.FC< + ProviderProps> + >; +} & { + [key in keyof Record, object>]: StoreApi; +} & { + [key in keyof Record, object>]: UseStoreApi; +}; + +const capitalizeFirstLetter = (str = '') => + str.length > 0 ? str[0].toUpperCase() + str.slice(1) : ''; +const getProviderIndex = (name = '') => + `${capitalizeFirstLetter(name)}Provider`; +const getStoreIndex = (name = '') => + name.length > 0 ? `${name}Store` : 'store'; +const getUseStoreIndex = (name = '') => + `use${capitalizeFirstLetter(name)}Store`; + +const isAtom = (possibleAtom: unknown): boolean => + !!possibleAtom && + typeof possibleAtom === 'object' && + 'read' in possibleAtom && + typeof possibleAtom.read === 'function'; + +const withDefaultOptions = ( + fnRecord: T, + defaultOptions: UseAtomOptions +): T => + Object.fromEntries( + Object.entries(fnRecord).map(([key, fn]) => [ + key, + (options: UseAtomOptions = {}) => + (fn as any)({ ...defaultOptions, ...options }), + ]) + ) as any; + +const convertScopeShorthand = ( + optionsOrScope: UseAtomOptionsOrScope = {} +): UseAtomOptions => + typeof optionsOrScope === 'string' + ? { scope: optionsOrScope } + : optionsOrScope; + +export interface CreateAtomStoreOptions< + T extends object, + E extends AtomRecord, + N extends string, +> { + name: N; + delay?: UseAtomOptions['delay']; + effect?: React.FC; + extend?: (atomsWithoutExtend: StoreAtomsWithoutExtend) => E; +} + +/** + * Create an atom store from an initial value. + * Each property will have a getter and setter. + * + * @example + * const { exampleStore, useExampleStore } = createAtomStore({ count: 1, say: 'hello' }, { name: 'example' as const }) + * const [count, setCount] = useExampleStore().use.count() + * const say = useExampleStore().get.say() + * const setSay = useExampleStore().set.say() + * setSay('world') + * const countAtom = exampleStore.atom.count + */ +export const createAtomStore = < + T extends object, + E extends AtomRecord, + N extends string = '', +>( + initialState: T, + { name, delay: delayRoot, effect, extend }: CreateAtomStoreOptions +): AtomStoreApi => { + type MyStoreAtoms = StoreAtoms; + type MyWritableStoreAtoms = WritableStoreAtoms; + type MyStoreAtomsWithoutExtend = StoreAtomsWithoutExtend; + type MyWritableStoreAtomsWithoutExtend = + FilterWritableAtoms; + type MyStoreInitialValues = StoreInitialValues; + + const providerIndex = getProviderIndex(name) as NameProvider; + const useStoreIndex = getUseStoreIndex(name) as UseNameStore; + const storeIndex = getStoreIndex(name) as NameStore; + + const atomsWithoutExtend = {} as MyStoreAtomsWithoutExtend; + const writableAtomsWithoutExtend = {} as MyWritableStoreAtomsWithoutExtend; + const atomIsWritable = {} as Record; + + for (const [key, atomOrValue] of Object.entries(initialState)) { + const atomConfig: Atom = isAtom(atomOrValue) + ? atomOrValue + : atomWithFn(atomOrValue); + atomsWithoutExtend[key as keyof MyStoreAtomsWithoutExtend] = + atomConfig as any; + + const writable = 'write' in atomConfig; + atomIsWritable[key as keyof MyStoreAtoms] = writable; + + if (writable) { + writableAtomsWithoutExtend[ + key as keyof MyWritableStoreAtomsWithoutExtend + ] = atomConfig as any; + } + } + + const atoms = { ...atomsWithoutExtend } as MyStoreAtoms; + + if (extend) { + const extendedAtoms = extend(atomsWithoutExtend); + + for (const [key, atomConfig] of Object.entries(extendedAtoms)) { + atoms[key as keyof MyStoreAtoms] = atomConfig; + atomIsWritable[key as keyof MyStoreAtoms] = 'write' in atomConfig; + } + } + + const getAtoms = {} as GetRecord; + const setAtoms = {} as SetRecord; + const useAtoms = {} as UseRecord; + + const useStore = (optionsOrScope: UseAtomOptionsOrScope = {}) => { + const { + scope, + store, + warnIfNoStore = true, + } = convertScopeShorthand(optionsOrScope); + const contextStore = useAtomStore(name, scope, !store && warnIfNoStore); + return store ?? contextStore; + }; + + const useAtomValueWithStore: GetAtomFn = (atomConfig, optionsOrScope) => { + const options = convertScopeShorthand(optionsOrScope); + const store = useStore({ warnIfNoStore: false, ...options }); + return useAtomValue(atomConfig, { + store, + delay: options.delay ?? delayRoot, + }); + }; + + const useSetAtomWithStore: SetAtomFn = (atomConfig, optionsOrScope) => { + const store = useStore(optionsOrScope); + return useSetAtom(atomConfig, { store }); + }; + + const useAtomWithStore: UseAtomFn = (atomConfig, optionsOrScope) => { + const store = useStore(optionsOrScope); + const { delay = delayRoot } = convertScopeShorthand(optionsOrScope); + return useAtom(atomConfig, { store, delay }); + }; + + for (const key of Object.keys(atoms)) { + const atomConfig = atoms[key as keyof MyStoreAtoms]; + const isWritable: boolean = atomIsWritable[key as keyof MyStoreAtoms]; + + (getAtoms as any)[key] = (optionsOrScope: UseAtomOptionsOrScope = {}) => + useAtomValueWithStore(atomConfig, optionsOrScope); + + if (isWritable) { + (setAtoms as any)[key] = (optionsOrScope: UseAtomOptionsOrScope = {}) => + useSetAtomWithStore( + atomConfig as WritableAtom, + optionsOrScope + ); + + (useAtoms as any)[key] = (optionsOrScope: UseAtomOptionsOrScope = {}) => + useAtomWithStore( + atomConfig as WritableAtom, + optionsOrScope + ); + } + } + + const Provider: React.FC> = + createAtomProvider( + name, + writableAtomsWithoutExtend, + { effect } + ); + + const storeApi: StoreApi = { + atom: atoms, + name, + }; + + const useStoreApi: UseStoreApi = (defaultOptions = {}) => ({ + get: { + ...withDefaultOptions(getAtoms, convertScopeShorthand(defaultOptions)), + atom: (atomConfig, options) => + useAtomValueWithStore(atomConfig, { + ...convertScopeShorthand(defaultOptions), + ...convertScopeShorthand(options), + }), + }, + set: { + ...withDefaultOptions(setAtoms, convertScopeShorthand(defaultOptions)), + atom: (atomConfig, options) => + useSetAtomWithStore(atomConfig, { + ...convertScopeShorthand(defaultOptions), + ...convertScopeShorthand(options), + }), + }, + use: { + ...withDefaultOptions(useAtoms, convertScopeShorthand(defaultOptions)), + atom: (atomConfig, options) => + useAtomWithStore(atomConfig, { + ...convertScopeShorthand(defaultOptions), + ...convertScopeShorthand(options), + }), + }, + store: (options) => + useStore({ + ...convertScopeShorthand(defaultOptions), + ...convertScopeShorthand(options), + }), + }); + + return { + [providerIndex]: Provider, + [useStoreIndex]: useStoreApi, + [storeIndex]: storeApi, + name, + } as any; +}; diff --git a/packages/solid-jotai-x/src/elementAtom.spec.tsx b/packages/solid-jotai-x/src/elementAtom.spec.tsx new file mode 100644 index 0000000..22fb729 --- /dev/null +++ b/packages/solid-jotai-x/src/elementAtom.spec.tsx @@ -0,0 +1,219 @@ +import '@testing-library/jest-dom'; + +import React from 'react'; +import { act, render } from '@testing-library/react'; + +import { createAtomStore } from './createAtomStore'; + +type TElement = any; + +export const SCOPE_ELEMENT = 'element'; + +export type ElementStoreValue = { element: TElement | null }; + +export const { useElementStore, ElementProvider } = createAtomStore( + { element: null } satisfies ElementStoreValue as ElementStoreValue, + { name: 'element' } as const +); + +/** + * Get the element by plugin key. + * If no element is found in the context, it will return an empty object. + */ +export const useElement = ( + pluginKey = SCOPE_ELEMENT +): T => { + const value = useElementStore(pluginKey).get.element(); + + if (!value) { + console.warn( + `The \`useElement(pluginKey)\` hook must be used inside the node component's context` + ); + return {} as T; + } + + return value as T; +}; + +describe('ElementProvider', () => { + interface TNameElement extends TElement { + type: 'name'; + name: string; + } + + interface TAgeElement extends TElement { + type: 'age'; + age: number; + } + + const makeNameElement = (name: string): TNameElement => ({ + type: 'name', + name, + children: [], + }); + + const makeAgeElement = (age: number): TAgeElement => ({ + type: 'age', + age, + children: [], + }); + + const NameElementProvider = ({ + name, + children, + }: { + name: string; + children: React.ReactNode; + }) => { + const element = React.useMemo(() => makeNameElement(name), [name]); + + return ( + + {children} + + ); + }; + + const AgeElementProvider = ({ + age, + children, + }: { + age: number; + children: React.ReactNode; + }) => { + const element = React.useMemo(() => makeAgeElement(age), [age]); + + return ( + + {children} + + ); + }; + + const UpdatingAgeElementProvider = ({ + initialAge, + increment, + buttonLabel, + children, + }: { + initialAge: number; + increment: number; + buttonLabel: string; + children: React.ReactNode; + }) => { + const [age, setAge] = React.useState(initialAge); + + return ( + + + {children} + + ); + }; + + interface ConsumerProps { + label?: string; + } + + const NameElementConsumer = ({ label = '' }: ConsumerProps) => { + const element = useElement('name'); + return
{label + element.name}
; + }; + + const AgeElementConsumer = ({ label = '' }: ConsumerProps) => { + const element = useElement('age'); + return
{label + element.age}
; + }; + + const TypeConsumer = ({ + type, + label = '', + }: ConsumerProps & { type?: 'name' | 'age' }) => { + const element = useElement(type); + return
{label + element.type}
; + }; + + const JsonConsumer = ({ + type, + label = '', + }: ConsumerProps & { type?: 'name' | 'age' }) => { + const element = useElement(type); + return
{label + JSON.stringify(element)}
; + }; + + it('returns the first ancestor matching the element type', () => { + const { getByText } = render( + + + + + + + + + + + + ); + + expect(getByText('Name: Jane')).toBeInTheDocument(); + expect(getByText('Age: 30')).toBeInTheDocument(); + expect(getByText('Type: age')).toBeInTheDocument(); + }); + + it('returns the first ancestor of any type if given type does not match', () => { + const { getByText } = render( + + + + + + ); + + expect(getByText('Type: name')).toBeInTheDocument(); + }); + + it('propagates updated elements to consumers', () => { + const { getByText } = render( + + + + + + + ); + + expect(getByText('Age 1: 20')).toBeInTheDocument(); + expect(getByText('Age 2: 140')).toBeInTheDocument(); + + act(() => getByText('updateAge1').click()); + + expect(getByText('Age 1: 30')).toBeInTheDocument(); + expect(getByText('Age 2: 140')).toBeInTheDocument(); + + act(() => getByText('updateAge2').click()); + + expect(getByText('Age 1: 30')).toBeInTheDocument(); + expect(getByText('Age 2: 150')).toBeInTheDocument(); + + act(() => getByText('updateAge1').click()); + + expect(getByText('Age 1: 40')).toBeInTheDocument(); + expect(getByText('Age 2: 150')).toBeInTheDocument(); + }); + + it('returns empty object if no ancestor exists', () => { + const { getByText } = render(); + expect(getByText('{}')).toBeInTheDocument(); + }); +}); diff --git a/packages/solid-jotai-x/src/index.ts b/packages/solid-jotai-x/src/index.ts new file mode 100644 index 0000000..981915b --- /dev/null +++ b/packages/solid-jotai-x/src/index.ts @@ -0,0 +1,8 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './atomWithFn'; +export * from './createAtomProvider'; +export * from './createAtomStore'; +export * from './useHydrateStore'; diff --git a/packages/solid-jotai-x/src/useHydrateStore.ts b/packages/solid-jotai-x/src/useHydrateStore.ts new file mode 100644 index 0000000..138ce50 --- /dev/null +++ b/packages/solid-jotai-x/src/useHydrateStore.ts @@ -0,0 +1,52 @@ +import React from 'react'; +import { useSetAtom } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; + +import { + SimpleWritableAtomRecord, + UseHydrateAtoms, + UseSyncAtoms, +} from './createAtomStore'; + +/** + * Hydrate atoms with initial values for SSR. + */ +export const useHydrateStore = ( + atoms: SimpleWritableAtomRecord, + initialValues: Parameters>[0], + options: Parameters>[1] = {} +) => { + const values: any[] = []; + + for (const key of Object.keys(atoms)) { + const initialValue = initialValues[key]; + + if (initialValue !== undefined) { + values.push([atoms[key], initialValue]); + } + } + + useHydrateAtoms(values, options); +}; + +/** + * Update atoms with new values on changes. + */ +export const useSyncStore = ( + atoms: SimpleWritableAtomRecord, + values: any, + { store }: Parameters>[1] = {} +) => { + for (const key of Object.keys(atoms)) { + const value = values[key]; + const atom = atoms[key]; + + const set = useSetAtom(atom, { store }); + + React.useEffect(() => { + if (value !== undefined && value !== null) { + set(value); + } + }, [set, value]); + } +}; diff --git a/packages/solid-jotai-x/tsconfig.json b/packages/solid-jotai-x/tsconfig.json new file mode 100644 index 0000000..425481e --- /dev/null +++ b/packages/solid-jotai-x/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../config/tsconfig.build.json", + "compilerOptions": { + "declarationDir": "./dist", + "outDir": "./dist" + }, + "include": ["src"] +} From 110d1941df64947cae1e0f3bab590bb352de156b Mon Sep 17 00:00:00 2001 From: rtritto Date: Sat, 17 Aug 2024 14:26:45 +0200 Subject: [PATCH 2/3] Init imports --- packages/solid-jotai-x/src/atomWithFn.ts | 2 +- .../solid-jotai-x/src/createAtomProvider.tsx | 26 ++++++++++--------- .../src/createAtomStore.spec.tsx | 19 ++++++++------ packages/solid-jotai-x/src/createAtomStore.ts | 15 ++++++----- .../solid-jotai-x/src/elementAtom.spec.tsx | 16 +++++++----- packages/solid-jotai-x/src/useHydrateStore.ts | 10 +++---- 6 files changed, 48 insertions(+), 40 deletions(-) diff --git a/packages/solid-jotai-x/src/atomWithFn.ts b/packages/solid-jotai-x/src/atomWithFn.ts index 5c759a1..3b28a6d 100644 --- a/packages/solid-jotai-x/src/atomWithFn.ts +++ b/packages/solid-jotai-x/src/atomWithFn.ts @@ -1,4 +1,4 @@ -import { atom } from 'jotai'; +import { atom } from 'solid-jotai'; import type { WritableAtom } from 'jotai/vanilla'; diff --git a/packages/solid-jotai-x/src/createAtomProvider.tsx b/packages/solid-jotai-x/src/createAtomProvider.tsx index 20b8e8a..f55c6e9 100644 --- a/packages/solid-jotai-x/src/createAtomProvider.tsx +++ b/packages/solid-jotai-x/src/createAtomProvider.tsx @@ -1,7 +1,9 @@ 'use client'; -import React from 'react'; -import { createStore } from 'jotai/vanilla'; +import { createStore } from 'solid-jotai'; +import { createContext, createEffect, createMemo, createSignal, useContext } from 'solid-js'; + +import type { Component, JSX } from 'solid-js'; import { JotaiStore, SimpleWritableAtomRecord } from './createAtomStore'; import { useHydrateStore, useSyncStore } from './useHydrateStore'; @@ -15,7 +17,7 @@ const getFullyQualifiedScope = (storeName: string, scope: string) => { * to reference any provider belonging to the store, regardless of scope. */ const PROVIDER_SCOPE = 'provider'; -const AtomStoreContext = React.createContext>( +const AtomStoreContext = createContext>( new Map() ); @@ -30,7 +32,7 @@ export const useAtomStore = ( scope: string = PROVIDER_SCOPE, warnIfUndefined: boolean = true ): JotaiStore | undefined => { - const storeContext = React.useContext(AtomStoreContext); + const storeContext = useContext(AtomStoreContext); const store = storeContext.get(getFullyQualifiedScope(storeName, scope)) ?? storeContext.get(getFullyQualifiedScope(storeName, PROVIDER_SCOPE)); @@ -49,7 +51,7 @@ export type ProviderProps = Partial & { scope?: string; initialValues?: Partial; resetKey?: any; - children: React.ReactNode; + children: JSX.Element; }; export const HydrateAtoms = ({ @@ -79,24 +81,24 @@ export const HydrateAtoms = ({ export const createAtomProvider = ( storeScope: N, atoms: SimpleWritableAtomRecord, - options: { effect?: React.FC } = {} + options: { effect?: Component } = {} ) => { const Effect = options.effect; // eslint-disable-next-line react/display-name return ({ store, scope, children, resetKey, ...props }: ProviderProps) => { const [storeState, setStoreState] = - React.useState(createStore()); + createSignal(createStore()); - React.useEffect(() => { + createEffect(() => { if (resetKey) { setStoreState(createStore()); } - }, [resetKey]); + }); - const previousStoreContext = React.useContext(AtomStoreContext); + const previousStoreContext = useContext(AtomStoreContext); - const storeContext = React.useMemo(() => { + const storeContext = createMemo(() => { const newStoreContext = new Map(previousStoreContext); if (scope) { @@ -114,7 +116,7 @@ export const createAtomProvider = ( ); return newStoreContext; - }, [previousStoreContext, scope, storeState]); + }); return ( diff --git a/packages/solid-jotai-x/src/createAtomStore.spec.tsx b/packages/solid-jotai-x/src/createAtomStore.spec.tsx index 56465c3..54a3450 100644 --- a/packages/solid-jotai-x/src/createAtomStore.spec.tsx +++ b/packages/solid-jotai-x/src/createAtomStore.spec.tsx @@ -1,9 +1,12 @@ import '@testing-library/jest-dom'; -import React from 'react'; import { act, queryByText, render, renderHook } from '@testing-library/react'; -import { atom, PrimitiveAtom, useAtomValue } from 'jotai'; import { splitAtom } from 'jotai/utils'; +import { atom, useAtomValue } from 'solid-jotai'; +import { createSignal } from 'solid-js'; + +import type { PrimitiveAtom } from 'jotai'; +import type { JSX } from 'solid-js'; import { createAtomStore } from './createAtomStore'; @@ -54,8 +57,8 @@ describe('createAtomStore', () => { const MUTABLE_PROVIDER_INITIAL_AGE = 19; const MUTABLE_PROVIDER_NEW_AGE = 20; - const MutableProvider = ({ children }: { children: React.ReactNode }) => { - const [age, setAge] = React.useState(MUTABLE_PROVIDER_INITIAL_AGE); + const MutableProvider = ({ children }: { children: JSX.Element }) => { + const [age, setAge] = createSignal(MUTABLE_PROVIDER_INITIAL_AGE); return ( <> @@ -74,9 +77,9 @@ describe('createAtomStore', () => { const BecomeFriendsProvider = ({ children, }: { - children: React.ReactNode; + children: JSX.Element; }) => { - const [becameFriends, setBecameFriends] = React.useState(false); + const [becameFriends, setBecameFriends] = createSignal(false); return ( <> @@ -111,7 +114,7 @@ describe('createAtomStore', () => { const BecomeFriendsSetter = () => { const setBecomeFriends = useMyTestStoreStore().set.becomeFriends(); - const [becameFriends, setBecameFriends] = React.useState(false); + const [becameFriends, setBecameFriends] = createSignal(false); return ( <> @@ -129,7 +132,7 @@ describe('createAtomStore', () => { const BecomeFriendsUser = () => { const [, setBecomeFriends] = useMyTestStoreStore().use.becomeFriends(); - const [becameFriends, setBecameFriends] = React.useState(false); + const [becameFriends, setBecameFriends] = createSignal(false); return ( <> diff --git a/packages/solid-jotai-x/src/createAtomStore.ts b/packages/solid-jotai-x/src/createAtomStore.ts index c52c764..3875986 100644 --- a/packages/solid-jotai-x/src/createAtomStore.ts +++ b/packages/solid-jotai-x/src/createAtomStore.ts @@ -1,12 +1,13 @@ -import React from 'react'; -import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { useHydrateAtoms } from 'jotai/utils'; +import { useAtom, useAtomValue, useSetAtom, type createStore } from 'solid-jotai'; +import { useHydrateAtoms } from 'solid-jotai/utils'; + +import type { Atom, WritableAtom } from 'jotai/vanilla'; +import type { Component } from 'solid-js'; import { atomWithFn } from './atomWithFn'; import { createAtomProvider, useAtomStore } from './createAtomProvider'; import type { ProviderProps } from './createAtomProvider'; -import type { Atom, createStore, WritableAtom } from 'jotai/vanilla'; export type JotaiStore = ReturnType; @@ -114,7 +115,7 @@ export type AtomStoreApi< > = { name: N; } & { - [key in keyof Record, object>]: React.FC< + [key in keyof Record, object>]: Component< ProviderProps> >; } & { @@ -164,7 +165,7 @@ export interface CreateAtomStoreOptions< > { name: N; delay?: UseAtomOptions['delay']; - effect?: React.FC; + effect?: Component; extend?: (atomsWithoutExtend: StoreAtomsWithoutExtend) => E; } @@ -287,7 +288,7 @@ export const createAtomStore = < } } - const Provider: React.FC> = + const Provider: Component> = createAtomProvider( name, writableAtomsWithoutExtend, diff --git a/packages/solid-jotai-x/src/elementAtom.spec.tsx b/packages/solid-jotai-x/src/elementAtom.spec.tsx index 22fb729..49ba631 100644 --- a/packages/solid-jotai-x/src/elementAtom.spec.tsx +++ b/packages/solid-jotai-x/src/elementAtom.spec.tsx @@ -1,7 +1,9 @@ import '@testing-library/jest-dom'; -import React from 'react'; import { act, render } from '@testing-library/react'; +import { createMemo, createSignal } from 'solid-js'; + +import type { JSX } from 'solid-js'; import { createAtomStore } from './createAtomStore'; @@ -63,9 +65,9 @@ describe('ElementProvider', () => { children, }: { name: string; - children: React.ReactNode; + children: JSX.Element; }) => { - const element = React.useMemo(() => makeNameElement(name), [name]); + const element = createMemo(() => makeNameElement(name), [name]); return ( @@ -79,9 +81,9 @@ describe('ElementProvider', () => { children, }: { age: number; - children: React.ReactNode; + children: JSX.Element; }) => { - const element = React.useMemo(() => makeAgeElement(age), [age]); + const element = createMemo(() => makeAgeElement(age), [age]); return ( @@ -99,9 +101,9 @@ describe('ElementProvider', () => { initialAge: number; increment: number; buttonLabel: string; - children: React.ReactNode; + children: JSX.Element; }) => { - const [age, setAge] = React.useState(initialAge); + const [age, setAge] = createSignal(initialAge); return ( diff --git a/packages/solid-jotai-x/src/useHydrateStore.ts b/packages/solid-jotai-x/src/useHydrateStore.ts index 138ce50..164d7dc 100644 --- a/packages/solid-jotai-x/src/useHydrateStore.ts +++ b/packages/solid-jotai-x/src/useHydrateStore.ts @@ -1,6 +1,6 @@ -import React from 'react'; -import { useSetAtom } from 'jotai'; -import { useHydrateAtoms } from 'jotai/utils'; +import { createEffect } from 'solid-js'; +import { useSetAtom } from 'solid-jotai'; +import { useHydrateAtoms } from 'solid-jotai/utils'; import { SimpleWritableAtomRecord, @@ -43,10 +43,10 @@ export const useSyncStore = ( const set = useSetAtom(atom, { store }); - React.useEffect(() => { + createEffect(() => { if (value !== undefined && value !== null) { set(value); } - }, [set, value]); + }); } }; From 3128f94447fa6eace1c3a7e4c296ac44282697a7 Mon Sep 17 00:00:00 2001 From: rtritto Date: Sat, 17 Aug 2024 14:26:54 +0200 Subject: [PATCH 3/3] Init package.json --- packages/solid-jotai-x/package.json | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/solid-jotai-x/package.json b/packages/solid-jotai-x/package.json index 735c8a0..0e2eca6 100644 --- a/packages/solid-jotai-x/package.json +++ b/packages/solid-jotai-x/package.json @@ -1,13 +1,13 @@ { - "name": "jotai-x", - "version": "1.2.4", + "name": "solid-jotai-x", + "version": "1.0.0", "description": "Jotai store factory for a best-in-class developer experience.", "license": "MIT", "homepage": "https://jotai-x.udecode.dev/", "repository": { "type": "git", "url": "https://github.com/udecode/jotai-x.git", - "directory": "packages/jotai-x" + "directory": "packages/solid-jotai-x" }, "bugs": { "url": "https://github.com/udecode/jotai-x/issues" @@ -39,20 +39,13 @@ "typecheck": "yarn p:typecheck" }, "peerDependencies": { - "@types/react": ">=17.0.0", - "jotai": ">=2.0.0", - "react": ">=17.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } + "jotai": ">=2", + "solid-jotai": ">=0", + "solid-js": ">=1" }, "keywords": [ - "jotai" + "jotai", + "solid-jotai" ], "publishConfig": { "access": "public"