diff --git a/README.md b/README.md index c8d8c23..8321217 100644 --- a/README.md +++ b/README.md @@ -1,321 +1,224 @@ -# Muya ๐ŸŒ€ -Welcome to Muya - Making state management a breeze, focused on simplicity and scalability for real-world scenarios. + +# **Muya ๐ŸŒ€** + +Muya is simple and lightweight react state management library. + +--- [![Build](https://github.com/samuelgja/muya/actions/workflows/build.yml/badge.svg)](https://github.com/samuelgja/muya/actions/workflows/build.yml) -[![Code quality Check](https://github.com/samuelgja/muya/actions/workflows/code-check.yml/badge.svg)](https://github.com/samuelgja/muya/actions/workflows/code-check.yml) +[![Code Quality Check](https://github.com/samuelgja/muya/actions/workflows/code-check.yml/badge.svg)](https://github.com/samuelgja/muya/actions/workflows/code-check.yml) [![Build Size](https://img.shields.io/bundlephobia/minzip/muya?label=Bundle%20size)](https://bundlephobia.com/result?p=muya) -#### There is a new version with complete re-work [Muya v2](https://github.com/samuelgja/muya/tree/feat/v2) -## ๐Ÿš€ Features -- Easy State Creation: Kickstart your state management with simple and intuitive APIs. -- Selectors & Merges: Grab exactly what you need from your state and combine multiple states seamlessly. -- Deep Nesting Support: Handle complex state structures without breaking a sweat. -- Optimized Rendering: Prevent unnecessary re-renders -- TypeScript Ready: Fully typed for maximum developer sanity. -- Small Bundle Size: Lightweight and fast, no bloatware here. -## ๐Ÿ“ฆ Installation +- **Simplified API**: Only `create` and `select`. +- **Batch Updates**: Built-in batching ensures efficient `muya` state updates internally. +- **TypeScript Support**: Type first. +- **Lightweight**: Minimal bundle size. + +--- + +## ๐Ÿ“ฆ **Installation** + +Install with your favorite package manager: ```bash -bun add muya +bun add muya@latest ``` -or ```bash -yarn add muya +npm install muya@latest ``` -or ```bash -npm install muya +yarn add muya@latest ``` -## ๐Ÿ“ Quick Start - -```typescript -import { create } from 'muya' +--- -const useCounter = create(0) +## ๐Ÿ“ **Quick Start** -function App() { - const counter = useCounter() - return
useCounter.setState((prev) => prev + 1)}>{counter}
-} -``` +### **Create and Use State** -### Update -Sugar syntax above the `setState` method for partially updating the state. ```typescript -import { create } from 'muya' +import { create } from 'muya'; -const useUser = create({ name: 'John', lastName: 'Doe' }) +const counter = create(0); -function App() { - const user = useUser() - // this will just partially update only the name field, it's sugar syntax for setState. - return
useUser.updateState({ name: 'Nope' })}>{user.name}
+// Access in a React component +function Counter() { + const count = counter(); // Call state directly + return ( +
+ +

Count: {count}

+
+ ); } ``` -### Selecting parts of the state globally -```tsx -import { create } from 'muya' - -const useUser = create({ name: 'John', age: 30 }) - -// Selecting only the name part of the state -const useName = useUser.select((user) => user.name) +--- -function App() { - const name = useName() - return
useUser.setState((prev) => ({ ...prev, name: 'Jane' }))}>{name}
-} +### **Select and Slice State** -``` +Use `select` to derive a slice of the state: -### Merge any states ```typescript -import { create, shallow, merge } from 'muya' +const state = create({ count: 0, value: 42 }); -const useName = create(() => 'John') -const useAge = create(() => 30) +const countSlice = state.select((s) => s.count); -const useFullName = merge([useName, useAge], (name, age) => `${name} and ${age}`) - -function App() { - const fullName = useFullName() - return
useName.setState((prev) => 'Jane')}>{fullName}
-} +// Also async is possible, but Muya do not recommend +// It can lead to spaghetti re-renders code which is hard to maintain and debug +const asyncCountSlice = state.select(async (s) => { + const data = await fetchData(); + return data.count; +}); ``` -### Promise based state and lifecycle management working with React Suspense -This methods are useful for handling async data fetching and lazy loading via React Suspense. +--- -#### Immediate Promise resolution -```typescript -import { create } from 'muya'; - // state will try to resolve the promise immediately, can hit the suspense boundary -const counterState = create(Promise.resolve(0)); +### **Combine Multiple States** -function Counter() { - const counter = counterState(); - return ( -
counterState.setState((prev) => prev + 1)}> - {counter} -
- ); -} -``` +Combine multiple states into a derived state: -#### Lazy Promise resolution ```typescript -import { create } from 'muya'; -// state will lazy resolve the promise on first access, this will hit the suspense boundary if the first access is from component and via `counterState.getState()` method -const counterState = create(() => Promise.resolve(0)); +const state1 = create(1); +const state2 = create(2); -function Counter() { - const counter = counterState(); - return ( -
counterState.setState((prev) => prev + 1)}> - {counter} -
- ); -} +const sum = select([state1, state2], (s1, s2) => s1 + s2); ``` -## ๐Ÿ” API Reference - -### `create` +### **Equality Check** -Creates a basic atom state. +Customize equality checks to prevent unnecessary updates: ```typescript -function create(defaultState: T, options?: StateOptions): StateSetter; +const state = create({ a: 1, b: 2 }, (prev, next) => prev.b === next.b); + +// Updates only when `b` changes +state.set((prev) => ({ ...prev, a: prev.a + 1 })); ``` -**Example:** +Or in select methods: ```typescript -const userState = create({ name: 'John', age: 30 }); +const derived = select([state1, state2], (s1, s2) => s1 + s2, (prev, next) => prev === next); ``` -### `select` +--- -Selects a slice of an existing state directly or via a selector function. -```typescript -// userState is ready to use as hook, so you can name it with `use` prefix -const userState = create({ name: 'John', age: 30 }); -// Direct selection outside the component, is useful for accessing the slices of the state in multiple components -const userAgeState = userState.select((user) => user.age); -``` +## ๐Ÿ–ฅ๏ธ **Using State in Components** -### `merge` -Merges any number states into a single state. -```typescript -const useName = create(() => 'John'); -const useAge = create(() => 30); -const useFullName = merge([useName, useAge], (name, age) => `${name} and ${age}`); -``` +Access state directly or through `useValue` hook: -### `setState` -Sets the state to a new value or a function that returns a new value. +### **Option 1: Access State Directly** ```typescript -const userState = create({ name: 'John', age: 30 }); -userState.setState({ name: 'Jane' }); -``` - -### `updateState` -Partially updates the state with a new value. +const userState = create(0); -```typescript -const userState = create({ name: 'John', age: 30 }); -userState.updateState({ name: 'Jane' }); +function App() { + const user = userState(); // Directly call state + return

User: {user}

; +} ``` -### `getState` -Returns the current state value outside the component. +### **Option 2: Use the Hook** ```typescript -const userState = create({ name: 'John', age: 30 }); -const user = userState.getState(); +import { useValue } from 'muya'; + +function App() { + const user = useValue(userState); // Access state via hook + return

User: {user}

; +} ``` -### `use` -Creates a hook for the state. +### **Option 3: Slice with Hook** ```typescript -const useCounter = create(0); -// use inside the component -const counter = useCounter(); +function App() { + const count = useValue(state, (s) => s.count); // Use selector in hook + return

Count: {count}

; +} ``` -### `subscribe` -Subscribes to the state changes. +--- -```typescript -const userState = create({ name: 'John', age: 30 }); -const unsubscribe = userState.subscribe((state) => console.log(state)); -``` +## ๐Ÿ“– **API Overview** -### Promise Handling +### **`create`** -#### Immediate Promise Resolution +Create a new state: ```typescript -import { create } from 'muya'; - -// State will try to resolve the promise immediately, can hit the suspense boundary -const counterState = create(Promise.resolve(0)); +const state = create(initialValue, isEqual?); -function Counter() { - const counter = counterState(); - return ( -
counterState.setState((prev) => prev + 1)}> - {counter} -
- ); -} +// Methods: +state.get(); // Get current value +state.set(value); // Update value +state.listen(listener); // Subscribe to changes +state.select(selector, isEqual?); // Create derived state +state.destroy(); // Unsubscribe from changes, useful for dynamic state creation in components +state.withName(name); // Add a name for debugging, otherwise it will be auto generated number ``` -#### Lazy Promise Resolution +### **`select`** -```typescript -import { create } from 'muya'; +Combine or derive new states: -// State will lazy resolve the promise on first access, this will hit the suspense boundary if the first access is from component and via `counterState.getState()` method -const counterState = create(() => Promise.resolve(0)); +```typescript +const derived = select([state1, state2], (s1, s2) => s1 + s2); -function Counter() { - const counter = counterState(); - return ( -
counterState.setState((prev) => prev + 1)}> - {counter} -
- ); -} +// Methods: +derived.get(); // Get current value +derived.listen(listener); // Subscribe to changes +derived.select(selector, isEqual?); // Create nested derived state +derived.destroy(); // Unsubscribe from changes, useful for dynamic state creation in components +derived.withName(name); // Add a name for debugging, otherwise it will be auto generated number ``` -#### Promise Rejection Handling +### **`useValue`** -```typescript -import { create } from 'muya'; +React hook to access state: -// State will reject the promise -const counterState = create(Promise.reject('Error occurred')); - -function Counter() { - try { - const counter = counterState(); - return
{counter}
; - } catch (error) { - return
Error: {error}
; - } -} +```typescript +const value = useValue(state, (s) => s.slice); // Optional selector ``` -#### Error Throwing +--- -```typescript -import { create } from 'muya'; +## โš ๏ธ **Notes** -// State will throw an error -const counterState = create(() => { - throw new Error('Error occurred'); -}); +- **Equality Check**: Prevent unnecessary updates by passing a custom equality function to `create` or `select`. +- **Batch Updates**: Muya batches internal updates for better performance, reducing communication overhead similarly how react do. +- **Async Selectors / Derives**: Muya has support for async selectors / derives, but do not recommend to use as it can lead to spaghetti re-renders code which is hard to maintain and debug, if you want so, you can or maybe you should consider using other alternatives like `Jotai`. -function Counter() { - try { - const counter = counterState(); - return
{counter}
; - } catch (error) { - return
Error: {error.message}
; - } -} -``` -#### Setting a state during promise resolution +`Muya` encourage use async updates withing sync state like this: ```typescript -import { create } from 'muya'; - -// State will resolve the promise and set the state -const counterState = create(Promise.resolve(0)); -// this will abort current promise and set the state to 10 -counterState.setState(10); -function Counter() { - const counter = counterState(); - return ( -
counterState.setState((prev) => prev + 1)}> - {counter} -
- ); +const state = create({ data: null }); +async function update() { + const data = await fetchData(); + state.set({ data }); } ``` +--- +But of course you can do -### Access from outside the component -:warning: Avoid using this method for state management in [React Server Components](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md), especially in Next.js 13+. It may cause unexpected behavior or privacy concerns. +Note: Handling async updates for the state (`set`) will cancel the previous pending promise. ```typescript -const userState = create({ name: 'John', age: 30 }); -const user = userState.getState(); +const state = create(0) +const asyncState = state.select(async (s) => { + await longPromise(100) + return s + 1 +}) ``` --- -### Slicing new references -:warning: Slicing data with new references can lead to maximum call stack exceeded error. -It's recommended to not use new references for the state slices, if you need so, use `shallow` or other custom equality checks. -```typescript -import { state, shallow } from 'muya'; -const userState = create({ name: 'John', age: 30 }); -// this slice will create new reference object on each call -const useName = userState.select((user) => ({newUser: user.name }), shallow); -``` - -## ๐Ÿค– Contributing -Contributions are welcome! Please read the [contributing guidelines](CONTRIBUTING.md) before submitting a pull request. - -## ๐Ÿงช Testing -Muya comes with a robust testing suite. Check out the state.test.tsx for examples on how to write your own tests. +### Debugging +`Muya` in dev mode automatically connects to the `redux` devtools extension if it is installed in the browser. For now devtool api is simple - state updates. -## ๐Ÿ“œ License +## ๐Ÿ™ **Acknowledgments** -Muya is [MIT licensed](LICENSE). +This library is a fun, experimental project and not a replacement for robust state management tools. For more advanced use cases, consider libraries like `Zustand`, `Jotai`, or `Redux`. +If you enjoy `Muya`, please give it a โญ๏ธ! :) diff --git a/build.ts b/build.ts index 47a7edf..1d3105c 100644 --- a/build.ts +++ b/build.ts @@ -20,16 +20,17 @@ async function getAllFiles(dir: string): Promise { return Array.prototype.concat(...files) } const execAsync = promisify(exec) -const entry = 'src/index.ts' +const entryDir = 'packages/core' +const entry = path.join(entryDir, 'index.ts') const outDir = 'lib' const external = ['react', 'react-native', 'use-sync-external-store/shim/with-selector'] // Ensure output directories await fs.mkdir(path.join(outDir, 'cjs'), { recursive: true }) await fs.mkdir(path.join(outDir, 'esm'), { recursive: true }) -await fs.mkdir(path.join(outDir, 'src'), { recursive: true }) +await fs.mkdir(path.join(outDir, entryDir), { recursive: true }) // Copy source files for react-native compatibility -await fs.cp('src', path.join(outDir, 'src'), { recursive: true }) +await fs.cp(entryDir, path.join(outDir, 'src'), { recursive: true }) // CommonJS build (single file) await esbuild.build({ @@ -44,7 +45,7 @@ await esbuild.build({ // ESM build (files as they are) await esbuild.build({ - entryPoints: await getAllFiles('src'), + entryPoints: await getAllFiles(entryDir), bundle: false, format: 'esm', outdir: path.join(outDir, 'esm'), @@ -60,6 +61,11 @@ import packageJson from './package.json' delete packageJson.scripts // @ts-ignore delete packageJson.devDependencies +// @ts-ignore +delete packageJson.private +// @ts-ignore +delete packageJson.workspaces + // Copy package.json and README.md await fs.writeFile(path.join(outDir, 'package.json'), JSON.stringify(packageJson, null, 2)) @@ -75,3 +81,5 @@ try { } catch { console.log('No .npmrc file found') } + +// Copy all core diff --git a/bun.lockb b/bun.lockb index 3fe43b3..8a65ba5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/eslint.config.base.mjs b/eslint.config.base.mjs index 19fca1e..ee4b461 100644 --- a/eslint.config.base.mjs +++ b/eslint.config.base.mjs @@ -19,7 +19,7 @@ const tsConfigPath = path.resolve("./", 'tsconfig.json') depend.configs['flat/recommended'], { ignores: ['**/*.js', '**/api-definitions.ts', '**/.expo/**/*.ts*', "**/dist/**", "**/.storybook/**", "lib/**/*"], - files: ['src/**/*.{ts,tsx}'], + files: ['packages/core/*.{ts,tsx}'], }, js.configs.recommended, // prettierRecommended, @@ -78,8 +78,7 @@ const tsConfigPath = path.resolve("./", 'tsconfig.json') '@typescript-eslint/return-await': ['off'], '@typescript-eslint/prefer-nullish-coalescing': ['off'], '@typescript-eslint/no-dynamic-delete': ['off'], - // '@typescript-eslint/prefer-optional-chain': ['error'], slow - '@typescript-eslint/ban-types': ['error'], + '@typescript-eslint/prefer-optional-chain': ['error'], '@typescript-eslint/no-var-requires': ['warn'], '@typescript-eslint/no-invalid-void-type': ['off'], '@typescript-eslint/explicit-function-return-type': ['off'], diff --git a/eslint.config.mjs b/eslint.config.mjs index e8a4efd..1551dc4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,7 +18,7 @@ const config = [ depend.configs['flat/recommended'], { ignores: ['**/*.js', '**/api-definitions.ts', '**/.expo/**/*.ts*', "**/dist/**", "**/.storybook/**", "lib/**/*"], - files: ['src/**/*.{ts,tsx}'], + files: ['packages/core/*.{ts,tsx}'], }, ...tailwind.configs['flat/recommended'], reactPerfPlugin.configs.flat.recommended, diff --git a/examples/muya-next-app/.eslintrc.json b/examples/muya-next-app/.eslintrc.json deleted file mode 100644 index 3722418..0000000 --- a/examples/muya-next-app/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript"] -} diff --git a/examples/muya-next-app/.gitignore b/examples/muya-next-app/.gitignore deleted file mode 100644 index d32cc78..0000000 --- a/examples/muya-next-app/.gitignore +++ /dev/null @@ -1,40 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# env files (can opt-in for committing if needed) -.env* - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/examples/muya-next-app/README.md b/examples/muya-next-app/README.md deleted file mode 100644 index e215bc4..0000000 --- a/examples/muya-next-app/README.md +++ /dev/null @@ -1,36 +0,0 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/examples/muya-next-app/app/favicon.ico b/examples/muya-next-app/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/examples/muya-next-app/app/favicon.ico and /dev/null differ diff --git a/examples/muya-next-app/app/fonts/GeistMonoVF.woff b/examples/muya-next-app/app/fonts/GeistMonoVF.woff deleted file mode 100644 index f2ae185..0000000 Binary files a/examples/muya-next-app/app/fonts/GeistMonoVF.woff and /dev/null differ diff --git a/examples/muya-next-app/app/fonts/GeistVF.woff b/examples/muya-next-app/app/fonts/GeistVF.woff deleted file mode 100644 index 1b62daa..0000000 Binary files a/examples/muya-next-app/app/fonts/GeistVF.woff and /dev/null differ diff --git a/examples/muya-next-app/app/globals.css b/examples/muya-next-app/app/globals.css deleted file mode 100644 index e3734be..0000000 --- a/examples/muya-next-app/app/globals.css +++ /dev/null @@ -1,42 +0,0 @@ -:root { - --background: #ffffff; - --foreground: #171717; -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -html, -body { - max-width: 100vw; - overflow-x: hidden; -} - -body { - color: var(--foreground); - background: var(--background); - font-family: Arial, Helvetica, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} - -a { - color: inherit; - text-decoration: none; -} - -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; - } -} diff --git a/examples/muya-next-app/app/layout.tsx b/examples/muya-next-app/app/layout.tsx deleted file mode 100644 index 5dd30a2..0000000 --- a/examples/muya-next-app/app/layout.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Metadata } from 'next' -import localFont from 'next/font/local' -import './globals.css' - -const geistSans = localFont({ - src: './fonts/GeistVF.woff', - variable: '--font-geist-sans', - weight: '100 900', -}) -const geistMono = localFont({ - src: './fonts/GeistMonoVF.woff', - variable: '--font-geist-mono', - weight: '100 900', -}) - -export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', -} - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode -}>) { - return ( - - {children} - - ) -} diff --git a/examples/muya-next-app/app/page-client.tsx b/examples/muya-next-app/app/page-client.tsx deleted file mode 100644 index 656aaad..0000000 --- a/examples/muya-next-app/app/page-client.tsx +++ /dev/null @@ -1,19 +0,0 @@ -'use client' -import Image from 'next/image' - -import styles from './page.module.css' -import { useAppState } from './state' - -useAppState.subscribe((value) => { - console.log('State subscribe from the client', value) -}) -export function PageClient() { - const appState = useAppState() - return ( -
- Next.js logo - {appState.greeting} - -
- ) -} diff --git a/examples/muya-next-app/app/page.module.css b/examples/muya-next-app/app/page.module.css deleted file mode 100644 index ee9b8e6..0000000 --- a/examples/muya-next-app/app/page.module.css +++ /dev/null @@ -1,168 +0,0 @@ -.page { - --gray-rgb: 0, 0, 0; - --gray-alpha-200: rgba(var(--gray-rgb), 0.08); - --gray-alpha-100: rgba(var(--gray-rgb), 0.05); - - --button-primary-hover: #383838; - --button-secondary-hover: #f2f2f2; - - display: grid; - grid-template-rows: 20px 1fr 20px; - align-items: center; - justify-items: center; - min-height: 100svh; - padding: 80px; - gap: 64px; - font-family: var(--font-geist-sans); -} - -@media (prefers-color-scheme: dark) { - .page { - --gray-rgb: 255, 255, 255; - --gray-alpha-200: rgba(var(--gray-rgb), 0.145); - --gray-alpha-100: rgba(var(--gray-rgb), 0.06); - - --button-primary-hover: #ccc; - --button-secondary-hover: #1a1a1a; - } -} - -.main { - display: flex; - flex-direction: column; - gap: 32px; - grid-row-start: 2; -} - -.main ol { - font-family: var(--font-geist-mono); - padding-left: 0; - margin: 0; - font-size: 14px; - line-height: 24px; - letter-spacing: -0.01em; - list-style-position: inside; -} - -.main li:not(:last-of-type) { - margin-bottom: 8px; -} - -.main code { - font-family: inherit; - background: var(--gray-alpha-100); - padding: 2px 4px; - border-radius: 4px; - font-weight: 600; -} - -.ctas { - display: flex; - gap: 16px; -} - -.ctas a { - appearance: none; - border-radius: 128px; - height: 48px; - padding: 0 20px; - border: none; - border: 1px solid transparent; - transition: - background 0.2s, - color 0.2s, - border-color 0.2s; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - line-height: 20px; - font-weight: 500; -} - -a.primary { - background: var(--foreground); - color: var(--background); - gap: 8px; -} - -a.secondary { - border-color: var(--gray-alpha-200); - min-width: 180px; -} - -.footer { - grid-row-start: 3; - display: flex; - gap: 24px; -} - -.footer a { - display: flex; - align-items: center; - gap: 8px; -} - -.footer img { - flex-shrink: 0; -} - -/* Enable hover only on non-touch devices */ -@media (hover: hover) and (pointer: fine) { - a.primary:hover { - background: var(--button-primary-hover); - border-color: transparent; - } - - a.secondary:hover { - background: var(--button-secondary-hover); - border-color: transparent; - } - - .footer a:hover { - text-decoration: underline; - text-underline-offset: 4px; - } -} - -@media (max-width: 600px) { - .page { - padding: 32px; - padding-bottom: 80px; - } - - .main { - align-items: center; - } - - .main ol { - text-align: center; - } - - .ctas { - flex-direction: column; - } - - .ctas a { - font-size: 14px; - height: 40px; - padding: 0 16px; - } - - a.secondary { - min-width: auto; - } - - .footer { - flex-wrap: wrap; - align-items: center; - justify-content: center; - } -} - -@media (prefers-color-scheme: dark) { - .logo { - filter: invert(); - } -} diff --git a/examples/muya-next-app/app/page.tsx b/examples/muya-next-app/app/page.tsx deleted file mode 100644 index d2c4f27..0000000 --- a/examples/muya-next-app/app/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import styles from './page.module.css' -import { useAppState } from './state' -import { PageClient } from './page-client' - -console.log('State subscribe from the server') -export default function Home() { - const appState = useAppState.getState() - return ( -
- {`${appState.greeting} From the server`} - -
- ) -} diff --git a/examples/muya-next-app/app/state.ts b/examples/muya-next-app/app/state.ts deleted file mode 100644 index 680905a..0000000 --- a/examples/muya-next-app/app/state.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { create } from '../../../src/index' - -export const useAppState = create({ - greeting: 'Hello, Muya!', -}) diff --git a/examples/muya-next-app/bun.lockb b/examples/muya-next-app/bun.lockb deleted file mode 100755 index 675e0d3..0000000 Binary files a/examples/muya-next-app/bun.lockb and /dev/null differ diff --git a/examples/muya-next-app/next.config.ts b/examples/muya-next-app/next.config.ts deleted file mode 100644 index 7329063..0000000 --- a/examples/muya-next-app/next.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextConfig } from 'next' - -const nextConfig: NextConfig = { - /* config options here */ -} - -export default nextConfig diff --git a/examples/muya-next-app/package.json b/examples/muya-next-app/package.json deleted file mode 100644 index 329bd7a..0000000 --- a/examples/muya-next-app/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "muya-next-app", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "muya": "^1.0.1", - "next": "15.0.3", - "react": "19.0.0-rc-66855b96-20241106", - "react-dom": "19.0.0-rc-66855b96-20241106" - }, - "devDependencies": { - "typescript": "^5", - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", - "eslint": "^8", - "eslint-config-next": "15.0.3" - } -} diff --git a/examples/muya-next-app/public/file.svg b/examples/muya-next-app/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/examples/muya-next-app/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/muya-next-app/public/globe.svg b/examples/muya-next-app/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/examples/muya-next-app/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/muya-next-app/public/next.svg b/examples/muya-next-app/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/examples/muya-next-app/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/muya-next-app/public/vercel.svg b/examples/muya-next-app/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/examples/muya-next-app/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/muya-next-app/public/window.svg b/examples/muya-next-app/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/examples/muya-next-app/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/muya-next-app/tsconfig.json b/examples/muya-next-app/tsconfig.json deleted file mode 100644 index d8b9323..0000000 --- a/examples/muya-next-app/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} diff --git a/package.json b/package.json index 588ef26..4653807 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,10 @@ { "name": "muya", - "version": "1.0.3", + "version": "2.0.0", "author": "samuel.gjabel@gmail.com", - "description": "๐Ÿ‘€ Another React state management library", - "license": "MIT", + "repository": "https://github.com/samuelgjabel/muya", "main": "cjs/index.js", "module": "esm/index.js", - "react-native": "src/index.ts", - "types": "types/index.d.ts", - "homepage": "https://github.com/samuelgjabel/muya", - "repository": "https://github.com/samuelgjabel/muya", - "sideEffects": false, - "keywords": [ - "react", - "react-hooks", - "react-native", - "state", - "management", - "library", - "muya", - "redux", - "zustand" - ], - "scripts": { - "build": "bun run build.ts", - "code-check": "bun run typecheck && bun run lint && bun run test && bun run format", - "typecheck": "tsc --noEmit", - "lint": "bun eslint \"src/**/*.{ts,tsx}\" --fix", - "format": "prettier --write \"./**/*.{js,jsx,json,ts,tsx}\"", - "test": "bun test", - "pub": "bun run code-check && bun run test && bun run build && cd lib && npm publish" - }, "devDependencies": { "@eslint-react/eslint-plugin": "1.15.1", "@eslint/compat": "^1.1.1", @@ -63,20 +37,35 @@ "eslint-plugin-tailwindcss": "^3.17.5", "eslint-plugin-unicorn": "55.0.0", "jest": "^29.2.1", + "jotai": "^2.10.2", "prettier": "3.3.3", "prettier-plugin-tailwindcss": "^0.6.5", "react-test-renderer": "18.2.0", + "reactotron-react-js": "^3.3.15", + "reactotron-react-native": "^5.1.10", + "redux-devtools-extension": "^2.13.9", "typescript": "5.6.3", "typescript-eslint": "^7.17.0", - "use-sync-external-store": "1.2.2" - }, - "dependencies": { - "use-sync-external-store": "1.2.2" + "zustand": "^5.0.1" }, "peerDependencies": { "use-sync-external-store": ">=1.2.0", "react": ">=18.0.0" }, + "description": "๐Ÿ‘€ Another React state management library", + "homepage": "https://github.com/samuelgjabel/muya", + "keywords": [ + "react", + "react-hooks", + "react-native", + "state", + "management", + "library", + "muya", + "redux", + "zustand" + ], + "license": "MIT", "peerDependenciesMeta": { "react": { "optional": true @@ -84,5 +73,22 @@ "use-sync-external-store": { "optional": true } - } + }, + "private": true, + "react-native": "src/index.ts", + "scripts": { + "clean": "sudo rm -rf node_modules && sudo rm -rf packages/*/node_modules && bun install", + "build": "bun run build.ts", + "code-check": "bun run typecheck && bun run lint && bun run test && bun run format", + "typecheck": "tsc --noEmit", + "lint": "bun eslint \"packages/core/**/*.{ts,tsx}\" --fix", + "format": "prettier --write \"./**/*.{js,jsx,json,ts,tsx}\"", + "test": "bun test", + "pub": "bun run code-check && bun run test && bun run build && cd lib && npm publish" + }, + "sideEffects": false, + "types": "types/index.d.ts", + "workspaces": [ + "packages/*" + ] } diff --git a/packages/core/__tests__/bench.test.tsx b/packages/core/__tests__/bench.test.tsx new file mode 100644 index 0000000..c5cfafd --- /dev/null +++ b/packages/core/__tests__/bench.test.tsx @@ -0,0 +1,161 @@ +/** + * This is not optimal, so for now just ignore. Its just for view and compare if the state is at least similar to others + * but this tests are not consider as a real benchmark + */ +/* eslint-disable unicorn/consistent-function-scoping */ +/* eslint-disable no-console */ + +import { act, renderHook } from '@testing-library/react-hooks' +import { useStore, create as zustand } from 'zustand' +import { useEffect, useState } from 'react' +import { useValue } from '../use-value' +import { atom, useAtom } from 'jotai' +import { create } from '../create' + +function renderPerfHook(hook: () => T, getValue: (data: T) => number, toBe: number) { + let onResolve = (_value: number) => {} + const resolvePromise = new Promise((resolve) => { + onResolve = resolve + }) + const start = performance.now() + const { result, waitFor } = renderHook(() => { + const data = hook() + const count = getValue(data) + useEffect(() => { + if (count === toBe) { + const end = performance.now() + onResolve(end - start) + } + }, [count]) + return data + }) + return { result, waitFor, resolvePromise } +} + +describe('benchmarks comparison measure', () => { + const reRendersBefore = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + const counts = [10_000] + for (const count of counts) { + describe(`Count ${count}`, () => { + it(`should benchmark ${count} muya first run - idk slow`, async () => { + const state = create(1) + // let count = 0 + + const { result, resolvePromise } = renderPerfHook( + () => { + reRendersBefore() + return useValue(state) + }, + (data) => data, + count - 1, + ) + + for (let index = 0; index < count; index++) { + act(() => { + state.set(index) + }) + } + + const time = await resolvePromise + expect(result.current).toBe(count - 1) + console.log('Time', time) + console.log('Renders', reRendersBefore.mock.calls.length) + }) + it(`should benchmark jotai ${count}`, async () => { + const state = atom(1) + + const { result, resolvePromise } = renderPerfHook( + () => { + reRendersBefore() + return useAtom(state) + }, + (data) => data[0], + count - 1, + ) + + for (let index = 0; index < count; index++) { + act(() => { + result.current[1](index) + }) + } + + const time = await resolvePromise + expect(result.current[0]).toBe(count - 1) + console.log('Time', time) + console.log('Renders', reRendersBefore.mock.calls.length) + }) + it(`should benchmark zustand ${count}`, async () => { + const state = zustand((_set) => ({ state: 1 })) + const { result, resolvePromise } = renderPerfHook( + () => { + reRendersBefore() + return useStore(state) + }, + (data) => data as number, + count - 1, + ) + + for (let index = 0; index < count; index++) { + act(() => { + state.setState(index) + }) + } + + const time = await resolvePromise + expect(result.current).toBe(count - 1) + console.log('Time', time) + console.log('Renders', reRendersBefore.mock.calls.length) + }) + + it(`should benchmark react ${count}`, async () => { + const { result, resolvePromise } = renderPerfHook( + () => { + reRendersBefore() + return useState(1) + }, + (data) => data[0], + count - 1, + ) + + for (let index = 0; index < count; index++) { + act(() => { + result.current[1](index) + }) + } + + const time = await resolvePromise + expect(result.current[0]).toBe(count - 1) + console.log('Time', time) + console.log('Renders', reRendersBefore.mock.calls.length) + }) + it(`should benchmark ${count} muya`, async () => { + const state = create(1) + // let count = 0 + + const { result, resolvePromise } = renderPerfHook( + () => { + reRendersBefore() + return useValue(state) + }, + (data) => data, + count - 1, + ) + + for (let index = 0; index < count; index++) { + act(() => { + state.set(index) + }) + } + + const time = await resolvePromise + expect(result.current).toBe(count - 1) + console.log('Time', time) + console.log('Renders', reRendersBefore.mock.calls.length) + }) + }) + } +}) diff --git a/packages/core/__tests__/create.test.tsx b/packages/core/__tests__/create.test.tsx new file mode 100644 index 0000000..472e856 --- /dev/null +++ b/packages/core/__tests__/create.test.tsx @@ -0,0 +1,159 @@ +import { create } from '../create' +import { waitFor } from '@testing-library/react' +import { longPromise } from './test-utils' + +describe('create', () => { + it('should get basic value states', async () => { + const state1 = create(1) + const state2 = create(2) + expect(state1.get()).toBe(1) + expect(state2.get()).toBe(2) + + state1.set(2) + state2.set(3) + + await waitFor(() => { + expect(state1.get()).toBe(2) + expect(state2.get()).toBe(3) + }) + }) + it('should check if value is subscribed to the state', async () => { + const state = create(1) + const listener = jest.fn() + state.listen(listener) + state.set(2) + await waitFor(() => { + expect(listener).toHaveBeenCalledWith(2) + }) + }) + + it('should check if value is unsubscribed from the state', async () => { + const state = create(1) + const listener = jest.fn() + const unsubscribe = state.listen(listener) + unsubscribe() + state.set(2) + await waitFor(() => { + expect(listener).not.toHaveBeenCalled() + }) + }) + it('should check change part of state, but is not equal', async () => { + const state = create({ count: 1, anotherCount: 1 }, (previous, next) => previous.anotherCount === next.anotherCount) + const listener = jest.fn() + state.listen(listener) + state.set((previous) => ({ ...previous, count: previous.count + 1 })) + await waitFor(() => { + expect(listener).not.toHaveBeenCalled() + }) + }) + it('should check change part of state, is not equal', async () => { + const state = create({ count: 1, anotherCount: 1 }, (previous, next) => previous.count === next.count) + const listener = jest.fn() + state.listen(listener) + state.set((previous) => ({ ...previous, count: previous.count + 1 })) + await waitFor(() => { + expect(listener).toHaveBeenCalledWith({ count: 2, anotherCount: 1 }) + }) + }) + + it('should initialize state with a function', () => { + const initialValue = jest.fn(() => 10) + const state = create(initialValue) + expect(initialValue).toHaveBeenCalled() + expect(state.get()).toBe(10) + }) + + it('should handle asynchronous state updates', async () => { + const state = create(0) + const listener = jest.fn() + state.listen(listener) + setTimeout(() => { + state.set(1) + }, 100) + await waitFor(() => { + expect(state.get()).toBe(1) + expect(listener).toHaveBeenCalledWith(1) + }) + }) + + it('should notify multiple listeners', async () => { + const state = create('initial') + const listener1 = jest.fn() + const listener2 = jest.fn() + state.listen(listener1) + state.listen(listener2) + state.set('updated') + await waitFor(() => { + expect(listener1).toHaveBeenCalledWith('updated') + expect(listener2).toHaveBeenCalledWith('updated') + }) + }) + + it('should not update if isEqual returns true', async () => { + const state = create(1, () => true) + const listener = jest.fn() + state.listen(listener) + state.set(2) + await waitFor(() => { + expect(listener).not.toHaveBeenCalled() + }) + }) + + it('should clear state and listeners on destroy', async () => { + const state = create(1) + const listener = jest.fn() + state.listen(listener) + state.destroy() + state.set(2) + await waitFor(() => {}) + expect(state.get()).toBe(1) + expect(listener).not.toHaveBeenCalledWith(2) + }) + + it('should create new get select state', async () => { + const state = create({ count: 1 }) + const select = state.select((slice) => slice.count) + expect(select.get()).toBe(1) + + state.set({ count: 2 }) + await waitFor(() => { + expect(select.get()).toBe(2) + }) + }) + + it('should create state with async value', async () => { + const state = create(() => longPromise(100)) + await waitFor(() => { + expect(state.get()).toBe(0) + }) + state.set(1) + await waitFor(() => { + expect(state.get()).toBe(1) + }) + }) + it('should create state with async value but will be cancelled by set value before it will resolve', async () => { + const state = create(() => longPromise(100)) + state.set(2) + await waitFor(() => { + expect(state.get()).toBe(2) + }) + }) + it('should handle async select', async () => { + const state = create(0) + const asyncState = state.select(async (s) => { + await longPromise(100) + return s + 1 + }) + const listener = jest.fn() + asyncState.listen(listener) + await waitFor(() => { + expect(asyncState.get()).toBe(1) + expect(listener).toHaveBeenCalledWith(1) + }) + state.set(1) + await waitFor(() => { + expect(asyncState.get()).toBe(2) + expect(listener).toHaveBeenCalledWith(2) + }) + }) +}) diff --git a/packages/core/__tests__/scheduler.test.tsx b/packages/core/__tests__/scheduler.test.tsx new file mode 100644 index 0000000..ed5c4b5 --- /dev/null +++ b/packages/core/__tests__/scheduler.test.tsx @@ -0,0 +1,52 @@ +import { waitFor } from '@testing-library/react' +import { createScheduler } from '../scheduler' + +describe('scheduler', () => { + it('should test scheduler by id', async () => { + const scheduler = createScheduler() + + const id = 1 + const value = 2 + const callback = jest.fn() + scheduler.add(id, { + onFinish: callback, + }) + scheduler.schedule(id, value) + await waitFor(() => { + expect(callback).toHaveBeenCalled() + }) + }) + it('should test scheduler with multiple ids', async () => { + const ids = [1, 2, 3] + const scheduler = createScheduler() + const callbacks: unknown[] = [] + for (const id of ids) { + const callback = jest.fn() + scheduler.add(id, { + onFinish: callback, + }) + callbacks.push(callback) + } + scheduler.schedule(1, 2) + await waitFor(() => { + expect(callbacks[0]).toHaveBeenCalled() + expect(callbacks[1]).not.toHaveBeenCalled() + expect(callbacks[2]).not.toHaveBeenCalled() + }) + jest.clearAllMocks() + scheduler.schedule(2, 2) + await waitFor(() => { + expect(callbacks[0]).not.toHaveBeenCalled() + expect(callbacks[1]).toHaveBeenCalled() + expect(callbacks[2]).not.toHaveBeenCalled() + }) + + jest.clearAllMocks() + scheduler.schedule(3, 2) + await waitFor(() => { + expect(callbacks[0]).not.toHaveBeenCalled() + expect(callbacks[1]).not.toHaveBeenCalled() + expect(callbacks[2]).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/core/__tests__/select.test.tsx b/packages/core/__tests__/select.test.tsx new file mode 100644 index 0000000..97a4790 --- /dev/null +++ b/packages/core/__tests__/select.test.tsx @@ -0,0 +1,127 @@ +import { create } from '../create' +import { select } from '../select' +import { waitFor } from '@testing-library/react' +import { longPromise } from './test-utils' + +describe('select', () => { + it('should derive state from a single dependency', async () => { + const state = create(1) + const selectedState = select([state], (value) => value * 2) + expect(selectedState.get()).toBe(2) + state.set(2) + await waitFor(() => {}) + expect(selectedState.get()).toBe(4) + }) + + it('should derive state from multiple dependencies', async () => { + const state1 = create(1) + const state2 = create(2) + const selectedState = select([state1, state2], (a, b) => a + b) + expect(selectedState.get()).toBe(3) + state1.set(2) + await waitFor(() => {}) + expect(selectedState.get()).toBe(4) + state2.set(3) + await waitFor(() => {}) + expect(selectedState.get()).toBe(5) + }) + + it('should notify listeners when derived state changes', async () => { + const state = create(1) + const selectedState = select([state], (value) => value * 2) + const listener = jest.fn() + selectedState.listen(listener) + state.set(2) + await waitFor(() => { + expect(selectedState.get()).toBe(4) + expect(listener).toHaveBeenCalledWith(4) + }) + }) + + it('should not notify listeners if isEqual returns true', async () => { + const state = create(1) + const selectedState = select( + [state], + (value) => value * 2, + () => true, + ) + const listener = jest.fn() + selectedState.listen(listener) + state.set(2) + await waitFor(() => { + expect(listener).not.toHaveBeenCalled() + }) + }) + + it('should destroy select state properly', async () => { + const state = create(1) + const selectedState = select([state], (value) => value * 2) + const listener = jest.fn() + selectedState.listen(listener) + selectedState.destroy() + state.set(2) + await waitFor(() => {}) + // there are no listeners to notify, so it return 4 as value is computed again, but internally it's destroyed and undefined + // so it works as expected + expect(selectedState.get()).toBe(4) + expect(listener).not.toHaveBeenCalled() + }) + it('should handle async updates', async () => { + const state1 = create(1) + const state2 = create(2) + const selectedState = select([state1, state2], async (a, b) => { + await longPromise() + return a + b + }) + const listener = jest.fn() + selectedState.listen(listener) + state1.set(2) + state2.set(3) + await waitFor(() => { + expect(selectedState.get()).toBe(5) + expect(listener).toHaveBeenCalledWith(5) + }) + }) + it('should handle async updates with async state', async () => { + const state = create(longPromise(100)) + const selectedState = select([state], async (value) => { + await longPromise(100) + return (await value) + 1 + }) + const listener = jest.fn() + selectedState.listen(listener) + await waitFor(() => { + expect(selectedState.get()).toBe(1) + expect(listener).toHaveBeenCalledWith(1) + }) + }) + it('should handle sync state updates when one of par is changed', async () => { + const state1Atom = create(0) + const state2Atom = create(0) + const state3Atom = create(0) + + const sumState = select([state1Atom, state2Atom, state3Atom], (a, b, c) => a + b + c) + + const listener = jest.fn() + sumState.listen(listener) + expect(sumState.get()).toBe(0) + + state1Atom.set(1) + await waitFor(() => { + expect(sumState.get()).toBe(1) + expect(listener).toHaveBeenCalledWith(1) + }) + + state2Atom.set(1) + await waitFor(() => { + expect(sumState.get()).toBe(2) + expect(listener).toHaveBeenCalledWith(2) + }) + + state3Atom.set(1) + await waitFor(() => { + expect(sumState.get()).toBe(3) + expect(listener).toHaveBeenCalledWith(3) + }) + }) +}) diff --git a/src/__tests__/test-utils.ts b/packages/core/__tests__/test-utils.ts similarity index 100% rename from src/__tests__/test-utils.ts rename to packages/core/__tests__/test-utils.ts diff --git a/packages/core/__tests__/use-value.test.tsx b/packages/core/__tests__/use-value.test.tsx new file mode 100644 index 0000000..ff58605 --- /dev/null +++ b/packages/core/__tests__/use-value.test.tsx @@ -0,0 +1,78 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { create } from '../create' +import { useValue } from '../use-value' +import { waitFor } from '@testing-library/react' + +describe('useValue', () => { + it('should get the initial state value', () => { + const state = create(1) + const { result } = renderHook(() => useValue(state)) + expect(result.current).toBe(1) + }) + + it('should get the initial state value', () => { + const state = create(1) + const { result } = renderHook(() => state()) + expect(result.current).toBe(1) + }) + + it('should update when the state changes', async () => { + const state = create(1) + const { result } = renderHook(() => useValue(state)) + act(() => { + state.set(2) + }) + await waitFor(() => { + expect(result.current).toBe(2) + }) + }) + + it('should use a selector function', () => { + const state = create({ count: 1 }) + const { result } = renderHook(() => useValue(state, (s) => s.count)) + expect(result.current).toBe(1) + }) + + it('should handle errors thrown from state', () => { + const error = new Error('Test error') + const state = create(() => { + throw error + }) + const { result } = renderHook(() => useValue(state)) + expect(result.error).toBe(error) + }) + + it('should handle promises returned from state suspense', async () => { + const promise = Promise.resolve(1) + const state = create(() => promise) + const renders = jest.fn() + const { result } = renderHook(() => { + renders() + return useValue(state) + }) + await waitFor(() => {}) + expect(result.current).toBe(1) + expect(renders).toHaveBeenCalledTimes(2) + }) + + it('should unsubscribe on unmount', async () => { + const state = create(1) + const renders = jest.fn() + const { unmount } = renderHook(() => { + renders() + const value = useValue(state) + return value + }) + act(() => { + state.set(2) + }) + await waitFor(() => {}) + expect(renders).toHaveBeenCalledTimes(2) + unmount() + act(() => { + state.set(3) + }) + await waitFor(() => {}) + expect(renders).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/core/create-state.ts b/packages/core/create-state.ts new file mode 100644 index 0000000..d3805fc --- /dev/null +++ b/packages/core/create-state.ts @@ -0,0 +1,50 @@ +import { select } from './select' +import type { GetState, SetValue, State } from './types' +import { useValue } from './use-value' +import { createEmitter } from './utils/create-emitter' +import { isEqualBase } from './utils/is' + +interface GetStateOptions { + readonly get: () => T + readonly set?: (value: SetValue) => void + readonly destroy: () => void +} + +let stateId = 0 +function getStateId() { + return stateId++ +} + +type FullState = GetStateOptions['set'] extends undefined ? GetState : State +/** + * This is just utility function to create state base data + */ +export function createState(options: GetStateOptions): FullState { + const { get, destroy, set } = options + const isSet = !!set + + const state: FullState = function (selector) { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useValue(state, selector) + } + state.isSet = isSet as true + state.id = getStateId() + state.emitter = createEmitter(get) + state.destroy = destroy + state.listen = function (listener) { + return this.emitter.subscribe(() => { + listener(get()) + }) + } + state.withName = function (name) { + this.stateName = name + return this + } + state.select = function (selector, isSelectorEqual = isEqualBase) { + return select([state], selector, isSelectorEqual) + } + state.get = get + state.set = set as State['set'] + + return state +} diff --git a/packages/core/create.ts b/packages/core/create.ts new file mode 100644 index 0000000..3ebfe7c --- /dev/null +++ b/packages/core/create.ts @@ -0,0 +1,67 @@ +import { canUpdate, handleAsyncUpdate } from './utils/common' +import { isEqualBase, isFunction, isSetValueFunction, isUndefined } from './utils/is' +import type { Cache, DefaultValue, IsEqual, SetValue, State } from './types' +import { createScheduler } from './scheduler' +import { subscribeToDevelopmentTools } from './debug/development-tools' +import { createState } from './create-state' + +export const stateScheduler = createScheduler() + +/** + * Create state from a default value. + */ +export function create(initialValue: DefaultValue, isEqual: IsEqual = isEqualBase): State { + const cache: Cache = {} + + function getValue(): T { + try { + if (isUndefined(cache.current)) { + const value = isFunction(initialValue) ? initialValue() : initialValue + const resolvedValue = handleAsyncUpdate(cache, state.emitter.emit, value) + cache.current = resolvedValue + } + return cache.current + } catch (error) { + cache.current = error as T + } + return cache.current + } + + function setValue(value: SetValue) { + if (cache.abortController) { + cache.abortController.abort() + } + + const previous = getValue() + const newValue = isSetValueFunction(value) ? value(previous) : value + const resolvedValue = handleAsyncUpdate(cache, state.emitter.emit, newValue) + cache.current = resolvedValue + } + + const state = createState({ + get: getValue, + destroy() { + getValue() + clearScheduler() + state.emitter.clear() + cache.current = undefined + }, + set(value: SetValue) { + stateScheduler.schedule(state.id, value) + }, + }) + + const clearScheduler = stateScheduler.add(state.id, { + onFinish() { + cache.current = getValue() + if (!canUpdate(cache, isEqual)) { + return + } + state.emitter.emit() + }, + onResolveItem: setValue, + }) + + subscribeToDevelopmentTools(state) + return state +} diff --git a/packages/core/debug/development-tools.ts b/packages/core/debug/development-tools.ts new file mode 100644 index 0000000..103e5cf --- /dev/null +++ b/packages/core/debug/development-tools.ts @@ -0,0 +1,52 @@ +import type { GetState, State } from '../types' +import { isPromise, isState } from '../utils/is' + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +const reduxDevelopmentTools = window?.__REDUX_DEVTOOLS_EXTENSION__?.connect({ + name: 'CustomState', // This will name your instance in the DevTools + trace: true, // Enables trace if needed +}) + +if (reduxDevelopmentTools) { + reduxDevelopmentTools.init({ message: 'Initial state' }) +} + +export type StateType = 'state' | 'derived' + +interface SendOptions { + message?: string + type: StateType + value: unknown + name: string +} +function sendToDevelopmentTools(options: SendOptions) { + if (!reduxDevelopmentTools) { + return + } + const { message, type, value, name } = options + if (isPromise(value)) { + return + } + reduxDevelopmentTools.send(name, { value, type, message }, type) +} + +function developmentToolsListener(name: string, type: StateType) { + return (value: unknown) => { + sendToDevelopmentTools({ name, type, value, message: 'update' }) + } +} + +export function subscribeToDevelopmentTools(state: State | GetState) { + if (process.env.NODE_ENV === 'production') { + return + } + let type: StateType = 'state' + + if (!isState(state)) { + type = 'derived' + } + const name = state.stateName?.length ? state.stateName : `${type}(${state.id.toString()})` + sendToDevelopmentTools({ name, type, value: state.get(), message: 'initial' }) + return state.listen(developmentToolsListener(name, type)) +} diff --git a/packages/core/index.ts b/packages/core/index.ts new file mode 100644 index 0000000..f036bd1 --- /dev/null +++ b/packages/core/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export { create } from './create' +export { select } from './select' +export { useValue } from './use-value' +export { shallow } from './utils/shallow' diff --git a/packages/core/scheduler.ts b/packages/core/scheduler.ts new file mode 100644 index 0000000..7ae9064 --- /dev/null +++ b/packages/core/scheduler.ts @@ -0,0 +1,81 @@ +export const THRESHOLD = 0.2 +export const THRESHOLD_ITEMS = 10 +export const RESCHEDULE_COUNT = 0 + +interface GlobalSchedulerItem { + value: T + id: number +} + +export interface SchedulerOptions { + readonly onResolveItem?: (item: T) => void + readonly onFinish: () => void +} + +export function createScheduler() { + const listeners = new Map>() + const batches = new Set>() + + let frame = performance.now() + let scheduled = false + + function schedule() { + const startFrame = performance.now() + const frameSizeDiffIn = startFrame - frame + const { size } = batches + if (frameSizeDiffIn < THRESHOLD && size > 0 && size < THRESHOLD_ITEMS) { + frame = startFrame + flush() + return + } + + if (!scheduled) { + scheduled = true + Promise.resolve().then(() => { + scheduled = false + frame = performance.now() + flush() + }) + } + } + + function flush() { + if (batches.size === 0) { + return + } + + const effectedListeners = new Set() + for (const value of batches) { + if (listeners.has(value.id)) { + effectedListeners.add(value.id) + const { onResolveItem } = listeners.get(value.id)! + if (onResolveItem) { + onResolveItem(value.value) + } + } + batches.delete(value) + } + + if (batches.size > RESCHEDULE_COUNT) { + schedule() + return + } + + for (const id of effectedListeners) { + listeners.get(id)?.onFinish() + } + } + + return { + add(id: number, option: SchedulerOptions) { + listeners.set(id, option as SchedulerOptions) + return () => { + listeners.delete(id) + } + }, + schedule(id: number, value: T) { + batches.add({ value, id }) + schedule() + }, + } +} diff --git a/packages/core/select.ts b/packages/core/select.ts new file mode 100644 index 0000000..ce9e64f --- /dev/null +++ b/packages/core/select.ts @@ -0,0 +1,69 @@ +import { stateScheduler } from './create' +import { createState } from './create-state' +import { subscribeToDevelopmentTools } from './debug/development-tools' +import type { Cache, GetState, IsEqual } from './types' +import { canUpdate, handleAsyncUpdate } from './utils/common' +import { isUndefined } from './utils/is' + +type StateDependencies> = { + [K in keyof T]: GetState +} + +/** + * Selecting state from multiple states. + * It will create new state in read-only mode (without set). + */ +export function select = []>( + states: StateDependencies, + selector: (...values: S) => T, + isEqual?: IsEqual, +): GetState { + const cache: Cache = {} + + function computedValue(): T { + const values = states.map((state) => state.get()) as S + return selector(...values) + } + + function getValue(): T { + if (isUndefined(cache.current)) { + const newValue = computedValue() + cache.current = handleAsyncUpdate(cache, state.emitter.emit, newValue) + } + return cache.current + } + + const cleanups: Array<() => void> = [] + for (const dependencyState of states) { + const clean = dependencyState.emitter.subscribe(() => { + stateScheduler.schedule(state.id, null) + }) + cleanups.push(clean) + } + + const state = createState({ + destroy() { + for (const cleanup of cleanups) { + cleanup() + } + clearScheduler() + state.emitter.clear() + cache.current = undefined + }, + get: getValue, + }) + + const clearScheduler = stateScheduler.add(state.id, { + onFinish() { + const newValue = computedValue() + cache.current = handleAsyncUpdate(cache, state.emitter.emit, newValue) + if (!canUpdate(cache, isEqual)) { + return + } + state.emitter.emit() + }, + }) + + subscribeToDevelopmentTools(state) + return state +} diff --git a/packages/core/types.ts b/packages/core/types.ts new file mode 100644 index 0000000..b7959b6 --- /dev/null +++ b/packages/core/types.ts @@ -0,0 +1,66 @@ +import type { Emitter } from './utils/create-emitter' + +export type IsEqual = (a: T, b: T) => boolean +export type SetStateCb = (value: T | Awaited) => Awaited +export type SetValue = SetStateCb | Awaited +export type DefaultValue = T | (() => T) +export type Listener = (listener: (value?: T) => void) => () => void +export interface Cache { + current?: T + previous?: T + abortController?: AbortController +} + +export const EMPTY_SELECTOR = (stateValue: T) => stateValue as unknown as S + +export interface GetState { + (selector?: (stateValue: T) => S): undefined extends S ? T : S + /** + * Get the cached state value. + */ + get: () => T + /** + * Get the unique id of the state. + */ + id: number + /** + * Emitter to listen to changes with snapshots. + */ + emitter: Emitter + /** + * Listen to changes in the state. + */ + listen: Listener + /** + * Destroy / cleanup the state. + * Clean all listeners and make cache value undefined. + */ + destroy: () => void + /** + * Set the state name. For debugging purposes. + */ + withName: (name: string) => GetState + /** + * Name of the state. For debugging purposes. + */ + stateName?: string + /** + * Select particular slice of the state. + * It will create "another" state in read-only mode (without set). + */ + select: (selector: (state: T) => S, isEqual?: IsEqual) => GetState +} + +export interface State extends GetState { + /** + * Setting new state value. + * It can be value or function that returns a value (similar to `setState` in React). + * If the state is initialized with async code, set will cancel the previous promise. + */ + set: (value: SetValue) => void + /** + * Set the state name. For debugging purposes. + */ + withName: (name: string) => State + isSet: true +} diff --git a/packages/core/use-value.ts b/packages/core/use-value.ts new file mode 100644 index 0000000..54dc9a9 --- /dev/null +++ b/packages/core/use-value.ts @@ -0,0 +1,22 @@ +import { useDebugValue, useSyncExternalStore } from 'react' +import { EMPTY_SELECTOR, type GetState } from './types' +import { isError, isPromise } from './utils/is' + +export function useValue(state: GetState, selector: (stateValue: T) => S = EMPTY_SELECTOR): undefined extends S ? T : S { + const { emitter } = state + const value = useSyncExternalStore( + state.emitter.subscribe, + () => selector(emitter.getSnapshot()), + () => selector(emitter.getInitialSnapshot ? emitter.getInitialSnapshot() : emitter.getSnapshot()), + ) + useDebugValue(value) + if (isPromise(value)) { + throw value + } + + if (isError(value)) { + throw value + } + + return value as undefined extends S ? T : S +} diff --git a/src/is.test.ts b/packages/core/utils/__tests__/is.test.ts similarity index 65% rename from src/is.test.ts rename to packages/core/utils/__tests__/is.test.ts index 832db12..b5e784b 100644 --- a/src/is.test.ts +++ b/packages/core/utils/__tests__/is.test.ts @@ -1,4 +1,5 @@ -import { isPromise, isFunction, isSetValueFunction, isObject, isRef, isMap, isSet, isArray, isEqualBase } from './is' +import { create } from '../../create' +import { isPromise, isFunction, isSetValueFunction, isMap, isSet, isArray, isEqualBase, isUndefined, isState } from '../is' describe('isPromise', () => { it('should return true for a Promise', () => { @@ -27,24 +28,6 @@ describe('isSetValueFunction', () => { }) }) -describe('isObject', () => { - it('should return true for an object', () => { - expect(isObject({})).toBe(true) - }) - it('should return false for a non-object', () => { - expect(isObject(123)).toBe(false) - }) -}) - -describe('isRef', () => { - it('should return true for a ref object', () => { - expect(isRef({ isRef: true })).toBe(true) - }) - it('should return false for a non-ref object', () => { - expect(isRef({})).toBe(false) - }) -}) - describe('isMap', () => { it('should return true for a Map', () => { expect(isMap(new Map())).toBe(true) @@ -79,12 +62,30 @@ describe('isEqualBase', () => { it('should return false for non-equal values', () => { expect(isEqualBase(1, 2)).toBe(false) }) +}) - it('should return true for NaN values', () => { - expect(isEqualBase(NaN, NaN)).toBe(true) +describe('isUndefined', () => { + it('should return true for undefined', () => { + // eslint-disable-next-line unicorn/no-useless-undefined + expect(isUndefined(undefined)).toBe(true) }) + it('should return false for a non-undefined', () => { + expect(isUndefined(123)).toBe(false) + }) +}) - it('should return false for different types', () => { - expect(isEqualBase(1, '1')).toBe(false) +describe('isState', () => { + it('should return true for a State real', () => { + const state = create(1) + expect(isState(state)).toBe(true) + }) + + it('should return true for a State with derived', () => { + const state = create(1) + const derived = state.select((v) => v) + expect(isState(derived)).toBe(false) + }) + it('should return false for a non-State', () => { + expect(isState(123)).toBe(false) }) }) diff --git a/src/__tests__/shallow.test.ts b/packages/core/utils/__tests__/shallow.test.ts similarity index 100% rename from src/__tests__/shallow.test.ts rename to packages/core/utils/__tests__/shallow.test.ts diff --git a/packages/core/utils/common.ts b/packages/core/utils/common.ts new file mode 100644 index 0000000..76b298f --- /dev/null +++ b/packages/core/utils/common.ts @@ -0,0 +1,73 @@ +import type { Cache, IsEqual } from '../types' +import { isAbortError, isEqualBase, isPromise, isUndefined } from './is' + +// eslint-disable-next-line no-shadow +export enum Abort { + Error = 'StateAbortError', +} + +export interface CancelablePromise { + promise: Promise + controller?: AbortController +} +/** + * Cancelable promise function, return promise and controller + */ +function cancelablePromise(promise: Promise, previousController?: AbortController): CancelablePromise { + if (previousController) { + previousController.abort() + } + const controller = new AbortController() + const { signal } = controller + + const cancelable = new Promise((resolve, reject) => { + // Listen for the abort event + signal.addEventListener('abort', () => { + reject(new DOMException('Promise was aborted', Abort.Error)) + }) + // When the original promise settles, resolve or reject accordingly + promise.then(resolve).catch(reject) + }) + return { promise: cancelable, controller } +} + +/** + * Check if the cache value is different from the previous value. + */ +export function canUpdate(cache: Cache, isEqual: IsEqual = isEqualBase): boolean { + if (!isUndefined(cache.current)) { + if (!isUndefined(cache.previous) && isEqual(cache.current, cache.previous)) { + return false + } + cache.previous = cache.current + } + return true +} + +/** + * Handle async updates for `create` and `select` + */ +export function handleAsyncUpdate(cache: Cache, emit: () => void, value: T): T { + if (!isPromise(value)) { + return value + } + if (cache.abortController) { + cache.abortController.abort() + } + + const { promise, controller } = cancelablePromise(value, cache.abortController) + cache.abortController = controller + + return promise + .then((result) => { + cache.current = result as T + emit() + }) + .catch((error) => { + if (isAbortError(error)) { + return + } + cache.current = error as T + emit() + }) as T +} diff --git a/packages/core/utils/create-emitter.ts b/packages/core/utils/create-emitter.ts new file mode 100644 index 0000000..c3bbb4c --- /dev/null +++ b/packages/core/utils/create-emitter.ts @@ -0,0 +1,55 @@ +export type EmitterSubscribe

= (listener: (...params: P[]) => void) => () => void +export interface Emitter { + subscribe: EmitterSubscribe

+ subscribeToOtherEmitter: (emitter: Emitter) => void + getSnapshot: () => T + getInitialSnapshot?: () => T + emit: (...params: P[]) => void + getSize: () => number + clear: () => void + contains: (listener: (...params: P[]) => void) => boolean +} + +/** + * Generics parameters are: + * T: Type of the state + * R: Type of the snapshot + * P: Type of the parameters + * @param getSnapshot + * @returns + */ +export function createEmitter(getSnapshot: () => T, getInitialSnapshot?: () => T): Emitter { + const listeners = new Set<(...params: P[]) => void>() + // const listeners = new WeakSet<(...params: P[]) => void>() + const otherCleaners: Array<() => void> = [] + return { + clear: () => { + for (const cleaner of otherCleaners) { + cleaner() + } + + listeners.clear() + }, + subscribe: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + emit: (...params) => { + for (const listener of listeners) { + listener(...params) + } + }, + contains: (listener) => listeners.has(listener), + getSnapshot, + getInitialSnapshot, + getSize: () => listeners.size, + subscribeToOtherEmitter(emitter) { + const clean = emitter.subscribe(() => { + this.emit() + }) + otherCleaners.push(clean) + }, + } +} diff --git a/src/is.ts b/packages/core/utils/is.ts similarity index 57% rename from src/is.ts rename to packages/core/utils/is.ts index 1b7a30d..7f28229 100644 --- a/src/is.ts +++ b/packages/core/utils/is.ts @@ -1,21 +1,13 @@ +import type { SetStateCb, SetValue, State } from '../types' import { Abort } from './common' -import type { Ref, Setter, SetValue } from './types' -export function isPromise(value: unknown): value is Promise { +export function isPromise(value: unknown): value is Promise { return value instanceof Promise } -export function isFunction(value: unknown): value is (...args: unknown[]) => unknown { - return typeof value === 'function' -} -export function isSetValueFunction(value: SetValue): value is Setter { + +export function isFunction unknown>(value: unknown): value is T { return typeof value === 'function' } -export function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} -export function isRef(value: unknown): value is Ref { - return isObject(value) && value.isRef === true -} export function isMap(value: unknown): value is Map { return value instanceof Map @@ -35,11 +27,21 @@ export function isEqualBase(valueA: T, valueB: T): boolean { } return !!Object.is(valueA, valueB) } - +export function isSetValueFunction(value: SetValue): value is SetStateCb { + return typeof value === 'function' +} export function isAbortError(value: unknown): value is DOMException { return value instanceof DOMException && value.name === Abort.Error } -export function isAnyOtherError(value: unknown): value is Error { - return value instanceof Error && value.name !== Abort.Error +export function isError(value: unknown): value is Error { + return value instanceof Error +} + +export function isUndefined(value: unknown): value is undefined { + return value === undefined +} + +export function isState(value: unknown): value is State { + return isFunction(value) && 'get' in value && 'set' in value && 'isSet' in value && value.isSet === true } diff --git a/src/shallow.ts b/packages/core/utils/shallow.ts similarity index 87% rename from src/shallow.ts rename to packages/core/utils/shallow.ts index 48af059..bf39dec 100644 --- a/src/shallow.ts +++ b/packages/core/utils/shallow.ts @@ -1,6 +1,6 @@ +/* eslint-disable sonarjs/cognitive-complexity */ import { isArray, isMap, isSet } from './is' -// eslint-disable-next-line sonarjs/cognitive-complexity export function shallow(valueA: T, valueB: T): boolean { if (valueA == valueB) { return true @@ -40,8 +40,8 @@ export function shallow(valueA: T, valueB: T): boolean { return true } - const keysA = Object.keys(valueA as Record) - const keysB = Object.keys(valueB as Record) + const keysA = Object.keys(valueA) + const keysB = Object.keys(valueB) if (keysA.length !== keysB.length) return false for (const key of keysA) { if ( diff --git a/packages/examples/vite-project/.gitignore b/packages/examples/vite-project/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/packages/examples/vite-project/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/examples/vite-project/README.md b/packages/examples/vite-project/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/packages/examples/vite-project/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/packages/examples/vite-project/bun.lockb b/packages/examples/vite-project/bun.lockb new file mode 100755 index 0000000..48a5bf2 Binary files /dev/null and b/packages/examples/vite-project/bun.lockb differ diff --git a/packages/examples/vite-project/index.html b/packages/examples/vite-project/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/packages/examples/vite-project/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +

+ + + diff --git a/packages/examples/vite-project/package.json b/packages/examples/vite-project/package.json new file mode 100644 index 0000000..2ff79bb --- /dev/null +++ b/packages/examples/vite-project/package.json @@ -0,0 +1,28 @@ +{ + "name": "vite-project", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react-swc": "^3.5.0", + "eslint": "^9.13.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.11.0", + "typescript": "~5.6.2", + "typescript-eslint": "^8.11.0", + "vite": "^5.4.10" + }, + "dependencies": { + "zustand": "^5.0.1" + } +} diff --git a/packages/examples/vite-project/public/vite.svg b/packages/examples/vite-project/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/packages/examples/vite-project/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/examples/vite-project/src/App.css b/packages/examples/vite-project/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/packages/examples/vite-project/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/packages/examples/vite-project/src/App.tsx b/packages/examples/vite-project/src/App.tsx new file mode 100644 index 0000000..59d9f8e --- /dev/null +++ b/packages/examples/vite-project/src/App.tsx @@ -0,0 +1,36 @@ +import { create, select } from '../../../core' + +// Define atoms for the three states +const state1Atom = create(0) +const state2Atom = create(0) +const state3Atom = create(0) + +export default function App() { + console.log('App render') + return ( +
+ + + + + +
+ ) +} + +const sumState = select([state1Atom, state2Atom, state3Atom], (a, b, c) => a + b + c) + +function ComponentChild1() { + console.log('ComponentChild1 render') + // Use the state atom in the child component + const state1 = sumState() + return
Sum of states: {state1}
+} diff --git a/packages/examples/vite-project/src/assets/react.svg b/packages/examples/vite-project/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/packages/examples/vite-project/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/examples/vite-project/src/index.css b/packages/examples/vite-project/src/index.css new file mode 100644 index 0000000..6119ad9 --- /dev/null +++ b/packages/examples/vite-project/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/packages/examples/vite-project/src/jotai-example.tsx b/packages/examples/vite-project/src/jotai-example.tsx new file mode 100644 index 0000000..c197d4c --- /dev/null +++ b/packages/examples/vite-project/src/jotai-example.tsx @@ -0,0 +1,33 @@ +import { atomFamily } from 'jotai/utils' +import { atom, useAtom, useSetAtom } from 'jotai' +import React, { useState } from 'react' + +// Define atoms for the counters +const counter1Atom = atom(0) +const counter2Atom = atom(0) + +// Use `atomFamily` to create parameterized atoms +const derivedSumAtomFamily = atomFamily((multiplier: number) => + atom((get) => { + const counter1 = get(counter1Atom) + const counter2 = get(counter2Atom) + return (counter1 + counter2) * multiplier + }), +) + +export default function App() { + const [counter1, setCounter1] = useAtom(counter1Atom) + const [counter2, setCounter2] = useAtom(counter2Atom) + const [multiplier, setMultiplier] = useState(1) + + // Use the derived atom from the atomFamily with the multiplier + const [derivedSum] = useAtom(derivedSumAtomFamily(multiplier)) + return ( +
+ + + + Result of multiplier: {derivedSum} +
+ ) +} diff --git a/packages/examples/vite-project/src/main.tsx b/packages/examples/vite-project/src/main.tsx new file mode 100644 index 0000000..5fb749d --- /dev/null +++ b/packages/examples/vite-project/src/main.tsx @@ -0,0 +1,6 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render() diff --git a/packages/examples/vite-project/src/muya-example.tsx b/packages/examples/vite-project/src/muya-example.tsx new file mode 100644 index 0000000..c197d4c --- /dev/null +++ b/packages/examples/vite-project/src/muya-example.tsx @@ -0,0 +1,33 @@ +import { atomFamily } from 'jotai/utils' +import { atom, useAtom, useSetAtom } from 'jotai' +import React, { useState } from 'react' + +// Define atoms for the counters +const counter1Atom = atom(0) +const counter2Atom = atom(0) + +// Use `atomFamily` to create parameterized atoms +const derivedSumAtomFamily = atomFamily((multiplier: number) => + atom((get) => { + const counter1 = get(counter1Atom) + const counter2 = get(counter2Atom) + return (counter1 + counter2) * multiplier + }), +) + +export default function App() { + const [counter1, setCounter1] = useAtom(counter1Atom) + const [counter2, setCounter2] = useAtom(counter2Atom) + const [multiplier, setMultiplier] = useState(1) + + // Use the derived atom from the atomFamily with the multiplier + const [derivedSum] = useAtom(derivedSumAtomFamily(multiplier)) + return ( +
+ + + + Result of multiplier: {derivedSum} +
+ ) +} diff --git a/packages/examples/vite-project/src/vite-env.d.ts b/packages/examples/vite-project/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/packages/examples/vite-project/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/examples/vite-project/tsconfig.app.json b/packages/examples/vite-project/tsconfig.app.json new file mode 100644 index 0000000..f867de0 --- /dev/null +++ b/packages/examples/vite-project/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/packages/examples/vite-project/tsconfig.json b/packages/examples/vite-project/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/packages/examples/vite-project/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/packages/examples/vite-project/tsconfig.node.json b/packages/examples/vite-project/tsconfig.node.json new file mode 100644 index 0000000..abcd7f0 --- /dev/null +++ b/packages/examples/vite-project/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/examples/vite-project/vite.config.ts b/packages/examples/vite-project/vite.config.ts new file mode 100644 index 0000000..2328e17 --- /dev/null +++ b/packages/examples/vite-project/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/src/__tests__/common.test.ts b/src/__tests__/common.test.ts deleted file mode 100644 index effa010..0000000 --- a/src/__tests__/common.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { cancelablePromise, toType, useSyncExternalStore } from '../common' -import { renderHook } from '@testing-library/react-hooks' -import { createEmitter } from '../create-emitter' -import { longPromise } from './test-utils' - -describe('toType', () => { - it('should cast object to specified type', () => { - const object = { a: 1 } - const result = toType<{ a: number }>(object) - expect(result.a).toBe(1) - }) -}) - -describe('useSyncExternalStore', () => { - it('should return the initial state value', () => { - const emitter = createEmitter(() => 0) - const { result } = renderHook(() => useSyncExternalStore(emitter, (state) => state)) - expect(result.current).toBe(0) - }) - - it('should update when the state value changes', () => { - let value = 0 - const emitter = createEmitter(() => value) - const { result } = renderHook(() => useSyncExternalStore(emitter, (state) => state)) - - value = 1 - emitter.emit() - expect(result.current).toBe(1) - }) - - it('should use the selector function', () => { - let value = 0 - const emitter = createEmitter(() => ({ count: value })) - const { result } = renderHook(() => useSyncExternalStore(emitter, (state: { count: number }) => state.count)) - - value = 1 - emitter.emit() - expect(result.current).toBe(1) - }) - - it('should use the isEqual function', () => { - let value = 0 - const emitter = createEmitter(() => ({ count: value })) - const isEqual = jest.fn((a, b) => a === b) - const { result } = renderHook(() => useSyncExternalStore(emitter, (state: { count: number }) => state.count, isEqual)) - - value = 1 - emitter.emit() - expect(result.current).toBe(1) - expect(isEqual).toHaveBeenCalled() - }) - - it('should test cancelable promise to abort', async () => { - const { promise, controller } = cancelablePromise(longPromise(1000 * 1000)) - controller.abort() - expect(promise).rejects.toThrow('aborted') - }) - - it('should test cancelable promise to resolve', async () => { - const { promise } = cancelablePromise(longPromise(0)) - expect(await promise).toBe(0) - }) -}) diff --git a/src/__tests__/create.test.tsx b/src/__tests__/create.test.tsx deleted file mode 100644 index d1a5e18..0000000 --- a/src/__tests__/create.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Suspense } from 'react' -import { create } from '../create' -import { renderHook, waitFor, act, render } from '@testing-library/react' -import { ErrorBoundary, longPromise } from './test-utils' - -describe('create', () => { - it('should create test with base value', () => { - const state = create(0) - const result = renderHook(() => state()) - expect(result.result.current).toBe(0) - }) - it('should create test with function', () => { - const state = create(() => 0) - const result = renderHook(() => state()) - expect(result.result.current).toBe(0) - }) - it('should create test with promise', async () => { - const state = create(Promise.resolve(0)) - const result = renderHook(() => state()) - await waitFor(() => { - expect(result.result.current).toBe(0) - }) - }) - it('should create test with promise and wait to be resolved', async () => { - const state = create(longPromise) - const result = renderHook(() => state(), { wrapper: ({ children }) => {children} }) - - await waitFor(() => { - expect(result.result.current).toBe(0) - }) - }) - it('should create test with lazy promise and wait to be resolved', async () => { - const state = create(async () => await longPromise()) - const result = renderHook(() => state(), { wrapper: ({ children }) => {children} }) - - await waitFor(() => { - expect(result.result.current).toBe(0) - }) - }) - it('should create test with promise and set value during the promise is pending', async () => { - const state = create(longPromise) - const result = renderHook(() => state(), { wrapper: ({ children }) => {children} }) - - act(() => { - state.setState(10) - }) - await waitFor(() => { - expect(result.result.current).toBe(10) - }) - }) - - it('should create test with lazy promise and set value during the promise is pending', async () => { - const state = create(async () => await longPromise()) - const result = renderHook(() => state(), { wrapper: ({ children }) => {children} }) - - act(() => { - state.setState(10) - }) - await waitFor(() => { - expect(result.result.current).toBe(10) - }) - }) - - it('should fail inside the hook when the promise is rejected with not abort isWithError', async () => { - const state = create(Promise.reject('error-message')) - - function Component() { - state() - return null - } - - const result = render( - An error occurred.}> - - - - , - ) - - await waitFor(() => { - expect(result.container.textContent).toBe('An error occurred.') - }) - }) -}) diff --git a/src/__tests__/is.test.ts b/src/__tests__/is.test.ts deleted file mode 100644 index 2ecb74d..0000000 --- a/src/__tests__/is.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Abort } from '../common' -import { - isPromise, - isFunction, - isSetValueFunction, - isObject, - isRef, - isMap, - isSet, - isArray, - isEqualBase, - isAbortError, -} from '../is' - -describe('isPromise', () => { - it('should return true for a Promise', () => { - expect(isPromise(Promise.resolve())).toBe(true) - }) - it('should return false for a non-Promise', () => { - expect(isPromise(123)).toBe(false) - }) -}) - -describe('isFunction', () => { - it('should return true for a function', () => { - expect(isFunction(() => {})).toBe(true) - }) - it('should return false for a non-function', () => { - expect(isFunction(123)).toBe(false) - }) -}) - -describe('isSetValueFunction', () => { - it('should return true for a function', () => { - expect(isSetValueFunction(() => {})).toBe(true) - }) - it('should return false for a non-function', () => { - expect(isSetValueFunction(123)).toBe(false) - }) -}) - -describe('isObject', () => { - it('should return true for an object', () => { - expect(isObject({})).toBe(true) - }) - it('should return false for a non-object', () => { - expect(isObject(123)).toBe(false) - }) -}) - -describe('isRef', () => { - it('should return true for a ref object', () => { - expect(isRef({ isRef: true })).toBe(true) - }) - it('should return false for a non-ref object', () => { - expect(isRef({})).toBe(false) - }) -}) - -describe('isMap', () => { - it('should return true for a Map', () => { - expect(isMap(new Map())).toBe(true) - }) - it('should return false for a non-Map', () => { - expect(isMap(123)).toBe(false) - }) -}) - -describe('isSet', () => { - it('should return true for a Set', () => { - expect(isSet(new Set())).toBe(true) - }) - it('should return false for a non-Set', () => { - expect(isSet(123)).toBe(false) - }) -}) - -describe('isArray', () => { - it('should return true for an array', () => { - expect(isArray([])).toBe(true) - }) - it('should return false for a non-array', () => { - expect(isArray(123)).toBe(false) - }) -}) - -describe('isEqualBase', () => { - it('should return true for equal values', () => { - expect(isEqualBase(1, 1)).toBe(true) - }) - it('should return false for non-equal values', () => { - expect(isEqualBase(1, 2)).toBe(false) - }) -}) - -describe('isAbortError', () => { - it('should return true for an AbortError', () => { - expect(isAbortError(new DOMException('', Abort.Error))).toBe(true) - }) - it('should return false for a non-AbortError', () => { - expect(isAbortError(new DOMException('', 'Error'))).toBe(false) - }) -}) diff --git a/src/__tests__/merge.test.ts b/src/__tests__/merge.test.ts deleted file mode 100644 index e721058..0000000 --- a/src/__tests__/merge.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { create } from '../create' -import { renderHook, act } from '@testing-library/react' -import { merge } from '../merge' - -describe('merge', () => { - it('should test merge multiple-state', () => { - const state1 = create(1) - const state2 = create(1) - const state3 = create(3) - - const useMerged = merge([state1, state2, state3], (value1, value2, value3) => { - expect(value1).toBe(1) - expect(value2).toBe(1) - expect(value3).toBe(3) - return `${value1} ${value2} ${value3}` - }) - const result = renderHook(() => useMerged()) - expect(result.result.current).toBe('1 1 3') - }) - - it('should test merge multiple-state with isEqual', () => { - const state1 = create(1) - const state2 = create(1) - const state3 = create(3) - - const useMerged = merge( - [state1, state2, state3], - (value1, value2, value3) => { - expect(value1).toBe(1) - expect(value2).toBe(1) - expect(value3).toBe(3) - return `${value1} ${value2} ${value3}` - }, - (a, b) => a === b, - ) - const result = renderHook(() => useMerged()) - expect(result.result.current).toBe('1 1 3') - }) - - it('should test merge multiple-state with different type', () => { - const state1 = create(1) - const state2 = create('1') - const state3 = create({ value: 3 }) - const state4 = create([1, 2, 3]) - - const useMerged = merge([state1, state2, state3, state4], (value1, value2, value3, value4) => { - expect(value1).toBe(1) - expect(value2).toBe('1') - expect(value3).toStrictEqual({ value: 3 }) - expect(value4).toStrictEqual([1, 2, 3]) - return `${value1} ${value2} ${value3.value} ${value4.join(' ')}` - }) - const result = renderHook(() => useMerged()) - expect(result.result.current).toBe('1 1 3 1 2 3') - }) - it('should test merge with reset', () => { - const state1 = create(1) - const state2 = create(1) - const state3 = create(3) - - const useMerged = merge([state1, state2, state3], (value1, value2, value3) => { - return `${value1} ${value2} ${value3}` - }) - const result = renderHook(() => useMerged()) - - act(() => { - state1.setState(2) - state2.setState(2) - state3.setState(4) - }) - expect(result.result.current).toBe('2 2 4') - - act(() => { - useMerged.reset() - }) - expect(result.result.current).toBe('1 1 3') - }) -}) diff --git a/src/__tests__/state.test.tsx b/src/__tests__/state.test.tsx deleted file mode 100644 index c0701ae..0000000 --- a/src/__tests__/state.test.tsx +++ /dev/null @@ -1,619 +0,0 @@ -/* eslint-disable @typescript-eslint/no-shadow */ -/* eslint-disable no-shadow */ -import { Suspense } from 'react' -import { create } from '../create' -import { renderHook, act, waitFor, render, screen } from '@testing-library/react' -import { shallow } from '../shallow' -describe('state', () => { - it('should test state', () => { - const appState = create({ count: 0 }) - expect(appState.getState()).toEqual({ count: 0 }) - }) - - it('should render state with promise hook', async () => { - const promise = Promise.resolve({ count: 100 }) - const appState = create(promise) - const renderCount = { current: 0 } - // const - - const result = renderHook(() => { - renderCount.current++ - return appState() - }) - // wait for the promise to be resolved - await waitFor(() => {}) - expect(result.result.current).toEqual({ count: 100 }) - // count rendered - expect(renderCount.current).toEqual(2) - expect(appState.getState()).toEqual({ count: 100 }) - }) - - it('should render state with get promise hook', async () => { - // eslint-disable-next-line unicorn/consistent-function-scoping - const getPromise = () => Promise.resolve({ count: 100 }) - const appState = create(getPromise) - const renderCount = { current: 0 } - // const - - const result = renderHook(() => { - renderCount.current++ - return appState() - }) - // wait for the promise to be resolved - await waitFor(() => {}) - act(() => { - appState.setState({ count: 15 }) - }) - expect(result.result.current).toEqual({ count: 15 }) - // count rendered - expect(renderCount.current).toEqual(3) - expect(appState.getState()).toEqual({ count: 15 }) - }) - - it('should render state with get promise check default', async () => { - // eslint-disable-next-line unicorn/consistent-function-scoping - const getPromise = () => Promise.resolve({ count: 100 }) - const appState = create(getPromise) - // const - - // wait for the promise to be resolved - await waitFor(() => {}) - act(() => { - appState.setState({ count: 15 }) - }) - expect(appState.getState()).toEqual({ count: 15 }) - // count rendered - act(() => { - appState.reset() - }) - expect(appState.getState()).toEqual({ count: 15 }) - }) - - it('should render state with get hook', async () => { - // eslint-disable-next-line unicorn/consistent-function-scoping - const get = () => ({ count: 100 }) - const appState = create(get) - const renderCount = { current: 0 } - // const - - const result = renderHook(() => { - renderCount.current++ - return appState() - }) - // wait for the promise to be resolved - await waitFor(() => {}) - act(() => { - appState.setState({ count: 15 }) - }) - expect(result.result.current).toEqual({ count: 15 }) - // count rendered - expect(renderCount.current).toEqual(2) - expect(appState.getState()).toEqual({ count: 15 }) - - act(() => { - appState.reset() - }) - expect(result.result.current).toEqual({ count: 100 }) - }) - - it('should render state with get', async () => { - let wasCalled = false - const get = () => { - wasCalled = true - return { count: 100 } - } - const appState = create(get) - expect(wasCalled).toEqual(false) - appState.getState() - expect(wasCalled).toEqual(true) - }) - it('should render state with get hook', async () => { - let wasCalled = false - const get = () => { - wasCalled = true - return { count: 100 } - } - const appState = create(get) - expect(wasCalled).toEqual(false) - renderHook(() => { - appState() - }) - expect(wasCalled).toEqual(true) - }) - - it('should render state with promise with suspense', async () => { - const promise = Promise.resolve({ count: 100 }) - const appState = create(promise) - const renderCount = { current: 0 } - - const MockedComponent = jest.fn(() =>
loading
) - const MockedComponentAfterSuspense = jest.fn(() =>
loaded
) - // const - function Component() { - renderCount.current++ - return ( -
- {appState().count} - -
- ) - } - render( - }> - - , - ) - expect(MockedComponent).toHaveBeenCalledTimes(1) - expect(MockedComponentAfterSuspense).toHaveBeenCalledTimes(0) - await waitFor(() => { - return screen.getByText('100') - }) - expect(MockedComponent).toHaveBeenCalledTimes(1) - expect(MockedComponentAfterSuspense).toHaveBeenCalledTimes(1) - }) - - it('should render state', () => { - const appState = create({ count: 0 }) - const renderCount = { current: 0 } - // const - - const result = renderHook(() => { - renderCount.current++ - return appState() - }) - expect(result.result.current).toEqual({ count: 0 }) - // count rendered - expect(renderCount.current).toEqual(1) - }) - - it('should render state', () => { - const appState = create({ count: 0 }) - const slice = appState.select((slice) => slice.count) - const renderCount = { current: 0 } - // const - - const result = renderHook(() => { - renderCount.current++ - return slice() - }) - expect(result.result.current).toEqual(0) - // count rendered - expect(renderCount.current).toEqual(1) - }) - - it('should render state with change', () => { - const appState = create({ count: 0 }) - const renderCount = { current: 0 } - // const - - const result = renderHook(() => { - renderCount.current++ - return appState((slice) => slice) - }) - - act(() => { - appState.setState({ count: 1 }) - }) - expect(result.result.current).toEqual({ count: 1 }) - expect(renderCount.current).toEqual(2) - }) - - it('should render state with slice change', () => { - const appState = create({ count: { nested: 0, array: [0] } }) - const renderCount = { current: 0 } - const useNestedSlice = appState.select((slice) => slice.count) - const useNestedSliceArray = appState.select((slice) => slice.count.array.length) - const result = renderHook(() => { - return appState() - }) - const sliceResult = renderHook(() => { - renderCount.current++ - return useNestedSlice() - }) - const sliceArrayResult = renderHook(() => { - return useNestedSliceArray() - }) - expect(sliceArrayResult.result.current).toEqual(1) - expect(sliceResult.result.current).toEqual({ nested: 0, array: [0] }) - act(() => { - appState.setState({ count: { nested: 2, array: [0] } }) - }) - - expect(result.result.current).toEqual({ count: { nested: 2, array: [0] } }) - expect(sliceResult.result.current).toEqual({ nested: 2, array: [0] }) - - act(() => { - appState.setState({ count: { nested: 2, array: [1, 2, 4] } }) - }) - expect(sliceArrayResult.result.current).toEqual(3) - }) - - it('should render multiple state', () => { - const mainState = create({ count: { nestedCount: 2 } }) - const slice1 = mainState.select((slice) => slice.count) - const slice2FromSlice1 = slice1.select((slice) => slice.nestedCount) - - const slice2FromSlice1Result = renderHook(() => slice2FromSlice1()) - expect(slice2FromSlice1Result.result.current).toEqual(2) - - act(() => { - mainState.setState({ count: { nestedCount: 3 } }) - }) - expect(slice2FromSlice1Result.result.current).toEqual(3) - }) - - it('should render multiple state with change', () => { - const appState = create({ count: 0 }) - const renderCount1 = { current: 0 } - const renderCount2 = { current: 0 } - // const - - const result1 = renderHook(() => { - renderCount1.current++ - return appState() - }) - const result2 = renderHook(() => { - renderCount2.current++ - return appState((slice) => slice.count) - }) - act(() => { - appState.setState({ count: 1 }) - }) - expect(result1.result.current).toEqual({ count: 1 }) - expect(result2.result.current).toEqual(1) - expect(renderCount1.current).toEqual(2) - expect(renderCount2.current).toEqual(2) - }) - - it('should test initial state', () => { - const appState = create({ count: 0 }) - expect(appState.getState()).toEqual({ count: 0 }) - }) - - it('should render initial state', () => { - const appState = create({ count: 0 }) - const renderCount = { current: 0 } - - const result = renderHook(() => { - renderCount.current++ - return appState() - }) - expect(result.result.current).toEqual({ count: 0 }) - expect(renderCount.current).toEqual(1) - }) - - it('should render state after change', () => { - const appState = create({ count: 0 }) - const renderCount = { current: 0 } - - const result = renderHook(() => { - renderCount.current++ - return appState((slice) => slice) - }) - - act(() => { - appState.setState({ count: 1 }) - }) - expect(result.result.current).toEqual({ count: 1 }) - expect(renderCount.current).toEqual(2) - }) - - it('should render state with nested slice change', () => { - const appState = create({ count: { nested: 0, array: [0] } }) - const renderCount = { current: 0 } - const useNestedSlice = appState.select((slice) => slice.count) - const useNestedSliceArray = appState.select((slice) => slice.count.array.length) - - const result = renderHook(() => appState()) - const sliceResult = renderHook(() => { - renderCount.current++ - return useNestedSlice() - }) - const sliceArrayResult = renderHook(() => useNestedSliceArray()) - - expect(sliceArrayResult.result.current).toEqual(1) - expect(sliceResult.result.current).toEqual({ nested: 0, array: [0] }) - - act(() => { - appState.setState({ count: { nested: 2, array: [0] } }) - }) - expect(result.result.current).toEqual({ count: { nested: 2, array: [0] } }) - expect(sliceResult.result.current).toEqual({ nested: 2, array: [0] }) - - act(() => { - appState.setState({ count: { nested: 2, array: [1, 2, 4] } }) - }) - expect(sliceArrayResult.result.current).toEqual(3) - }) - - it('should render multiple state slices with updates', () => { - const mainState = create({ count: { nestedCount: 2 } }) - const slice1 = mainState.select((slice) => slice.count) - const slice2FromSlice1 = slice1.select((slice) => slice.nestedCount) - - const slice2FromSlice1Result = renderHook(() => slice2FromSlice1()) - expect(slice2FromSlice1Result.result.current).toEqual(2) - - act(() => { - mainState.setState({ count: { nestedCount: 3 } }) - }) - expect(slice2FromSlice1Result.result.current).toEqual(3) - }) - - it('should render multiple components observing the same state', () => { - const appState = create({ count: 0 }) - const renderCount1 = { current: 0 } - const renderCount2 = { current: 0 } - - const result1 = renderHook(() => { - renderCount1.current++ - return appState() - }) - const result2 = renderHook(() => { - renderCount2.current++ - return appState((slice) => slice.count) - }) - - act(() => { - appState.setState({ count: 1 }) - }) - expect(result1.result.current).toEqual({ count: 1 }) - expect(result2.result.current).toEqual(1) - expect(renderCount1.current).toEqual(2) - expect(renderCount2.current).toEqual(2) - }) - - it('should reset state to default value', () => { - const appState = create({ count: 0 }) - act(() => { - appState.setState({ count: 10 }) - }) - expect(appState.getState()).toEqual({ count: 10 }) - - act(() => { - appState.reset() - }) - expect(appState.getState()).toEqual({ count: 0 }) - }) - - it('should handle updates with deep nesting in state', () => { - const appState = create({ data: { nested: { value: 1 } } }) - const nestedSlice = appState.select((s) => s.data.nested.value) - - const result = renderHook(() => nestedSlice()) - expect(result.result.current).toEqual(1) - - act(() => { - appState.setState({ data: { nested: { value: 2 } } }) - }) - expect(result.result.current).toEqual(2) - }) - - it('should not re-render for unrelated slice changes', () => { - const appState = create({ count: 0, unrelated: 5 }) - const renderCount = { current: 0 } - - const countSlice = appState.select((state) => state.count) - const unrelatedSlice = appState.select((state) => state.unrelated) - - renderHook(() => { - renderCount.current++ - return countSlice() - }) - - const unrelatedResult = renderHook(() => unrelatedSlice()) - expect(renderCount.current).toEqual(1) - - act(() => { - return appState.setState({ unrelated: 10 } as never) - }) - - expect(unrelatedResult.result.current).toEqual(10) - expect(renderCount.current).toEqual(2) // No re-render for count slice - }) - - it('should not re-render where isEqual return true on state', () => { - const appState = create({ count: 0 }, () => true) - const renderCount = { current: 0 } - - renderHook(() => { - renderCount.current++ - return appState() - }) - - act(() => { - appState.setState({ count: 10 }) - }) - - expect(renderCount.current).toEqual(1) - }) - - it('should not re-render where isEqual return true hook slice', () => { - const appState = create({ count: 0 }) - const renderCount = { current: 0 } - - renderHook(() => { - renderCount.current++ - return appState( - (slice) => slice, - () => true, - ) - }) - - act(() => { - appState.setState({ count: 10 }) - }) - - expect(renderCount.current).toEqual(1) - }) - - it('should not re-render where isEqual return true on slice', () => { - const appState = create({ count: 0 }) - const appStateSlice = appState.select( - (slice) => slice.count, - () => true, - ) - const renderCount = { current: 0 } - - renderHook(() => { - renderCount.current++ - return appStateSlice() - }) - - act(() => { - appState.setState({ count: 10 }) - }) - expect(renderCount.current).toEqual(1) - }) - - it('should not re-render where isEqual return true on nested slice', () => { - const appState = create({ count: { nested: { count: 0 } } }) - const appStateSlice = appState.select((slice) => slice.count) - const nestedAppSlice = appStateSlice.select( - (slice) => slice.nested.count, - () => true, - ) - const renderCount = { current: 0 } - - renderHook(() => { - renderCount.current++ - return nestedAppSlice() - }) - - act(() => { - appState.setState({ count: { nested: { count: 10 } } }) - }) - expect(renderCount.current).toEqual(1) - }) - - it('should use slice with new reference', () => { - const useName = create(() => 'John') - const useDifferentName = useName.select( - (name) => ({ - name, - }), - shallow, - ) - const result = renderHook(() => useDifferentName()) - expect(result.result.current).toEqual({ name: 'John' }) - act(() => { - useName.setState('Jane') - }) - expect(result.result.current).toEqual({ name: 'Jane' }) - }) - it('should check if subscribe works', () => { - const appState = create({ count: 0 }) - let count = 0 - const unsubscribe = appState.subscribe((state) => { - expect(state).toEqual({ count }) - count++ - }) - - act(() => { - appState.setState({ count: 1 }) - }) - expect(count).toEqual(2) - - unsubscribe() - act(() => { - appState.setState({ count: 2 }) - }) - expect(count).toEqual(2) - }) - - it('should handle rapid consecutive state updates', () => { - const appState = create({ count: 0 }) - const renderCount = { current: 0 } - - const result = renderHook(() => { - renderCount.current++ - return appState() - }) - - act(() => { - // batch updates - appState.setState({ count: 1 }) - appState.setState({ count: 2 }) - appState.setState({ count: 3 }) - }) - - expect(result.result.current).toEqual({ count: 3 }) - expect(renderCount.current).toEqual(2) // it's batch - }) - - it('should handle setting state to the same value', () => { - const appState = create({ count: 0 }) - const renderCount = { current: 0 } - - const result = renderHook(() => { - renderCount.current++ - return appState() - }) - - act(() => { - appState.setState((previous) => previous) - }) - - expect(result.result.current).toEqual({ count: 0 }) - expect(renderCount.current).toEqual(1) - }) - - it('should handle setting state with partial updates', () => { - const appState = create({ count: 0, name: 'John' }) - const renderCount = { current: 0 } - - const result = renderHook(() => { - renderCount.current++ - return appState() - }) - - act(() => { - appState.updateState({ count: 1 }) - }) - - expect(result.result.current).toEqual({ count: 1, name: 'John' }) - expect(renderCount.current).toEqual(2) - }) - - it('should handle resetting state after multiple updates', () => { - const appState = create({ count: 0 }) - const renderCount = { current: 0 } - - const result = renderHook(() => { - renderCount.current++ - return appState() - }) - - act(() => { - appState.setState({ count: 1 }) - appState.setState({ count: 2 }) - appState.reset() - }) - - expect(result.result.current).toEqual({ count: 0 }) - expect(renderCount.current).toEqual(2) - }) - - it('should handle concurrent asynchronous state updates', async () => { - const appState = create({ count: 0 }) - const renderCount = { current: 0 } - - const result = renderHook(() => { - renderCount.current++ - return appState() - }) - - act(() => { - // Simulate concurrent asynchronous updates - appState.setState({ count: 1 }) - appState.setState({ count: 2 }) - appState.setState({ count: 3 }) - }) - - await waitFor(() => { - expect(result.result.current).toEqual({ count: 3 }) - }) - - expect(renderCount.current).toBe(2) - }) -}) diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts deleted file mode 100644 index ee5ddc7..0000000 --- a/src/__tests__/types.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getDefaultValue } from '../types' - -describe('getDefaultValue', () => { - it('should return the value if it is not a function or promise', () => { - expect(getDefaultValue(123)).toBe(123) - }) - - it('should return the resolved value if it is a function', () => { - expect(getDefaultValue(() => 123)).toBe(123) - }) - - it('should return the promise if it is a promise', () => { - const promise = Promise.resolve(123) - expect(getDefaultValue(promise)).toBe(promise) - expect(getDefaultValue(() => promise)).toBe(promise) - }) -}) diff --git a/src/common.ts b/src/common.ts deleted file mode 100644 index 2c3f612..0000000 --- a/src/common.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useSyncExternalStoreWithSelector as useSync } from 'use-sync-external-store/shim/with-selector' -import type { Emitter } from './create-emitter' -import type { IsEqual } from './types' -import { useDebugValue } from 'react' - -/** - * Todo need to remove this - */ -export function toType(object?: unknown): T { - return object as T -} - -export function useSyncExternalStore( - emitter: Emitter, - selector: (stateValue: T) => S, - isEqual?: IsEqual, -): undefined extends S ? T : S { - const value = useSync( - emitter.subscribe, - emitter.getSnapshot, - emitter.getSnapshot, - selector ? (stateValue) => selector(stateValue) : toType, - isEqual, - ) as undefined extends S ? T : S - - useDebugValue(value) - return value -} -// eslint-disable-next-line no-shadow -export enum Abort { - Error = 'StateAbortError', -} -/** - * Cancelable promise function, return promise and controller - */ -export function cancelablePromise( - promise: Promise, - previousController?: AbortController, -): { - promise: Promise - controller: AbortController -} { - if (previousController) { - previousController.abort() - } - const controller = new AbortController() - const { signal } = controller - - const cancelable = new Promise((resolve, reject) => { - // Listen for the abort event - signal.addEventListener('abort', () => { - reject(new DOMException('Promise was aborted', Abort.Error)) - }) - - // When the original promise settles, resolve or reject accordingly - promise.then(resolve).catch(reject) - }) - - return { promise: cancelable, controller } -} diff --git a/src/create-base-state.ts b/src/create-base-state.ts deleted file mode 100644 index 46ebf4b..0000000 --- a/src/create-base-state.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Emitter } from './create-emitter' -import { select } from './select' -import type { BaseState, GetterState } from './types' - -interface Options { - readonly emitter: Emitter - readonly reset: () => void - readonly getState: () => T - readonly getGetterState: () => GetterState -} -export function createBaseState(options: Options): BaseState { - const { emitter, getGetterState, reset, getState } = options - return { - getState, - reset, - select(selector, isSame) { - const state = getGetterState() - return select(state, selector, isSame) - }, - - __internal: { - emitter, - }, - subscribe(listener) { - listener(getState()) - return emitter.subscribe(() => { - listener(getState()) - }) - }, - } -} diff --git a/src/create-emitter.ts b/src/create-emitter.ts deleted file mode 100644 index fb3a264..0000000 --- a/src/create-emitter.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type EmitterSubscribe

= (listener: (...params: P[]) => void) => () => void -export interface Emitter { - subscribe: EmitterSubscribe

- getSnapshot: () => R - emit: (...params: P[]) => void -} - -export function createEmitter(getSnapshot: () => R): Emitter { - const listeners = new Set<(...params: P[]) => void>() - return { - subscribe: (listener) => { - listeners.add(listener) - return () => { - listeners.delete(listener) - } - }, - emit: (...params) => { - for (const listener of listeners) { - listener(...params) - } - }, - getSnapshot, - } -} diff --git a/src/create-getter-state.ts b/src/create-getter-state.ts deleted file mode 100644 index 36123fc..0000000 --- a/src/create-getter-state.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { BaseState, GetterState } from './types' -import { useStateValue } from './use-state-value' - -interface Options { - readonly baseState: BaseState -} -export function createGetterState(options: Options): GetterState { - const { baseState } = options - const useSliceState: GetterState = (useSelector, isEqualHook) => { - return useStateValue(useSliceState, useSelector, isEqualHook) - } - useSliceState.__internal = baseState.__internal - useSliceState.getState = baseState.getState - useSliceState.reset = baseState.reset - useSliceState.select = baseState.select - useSliceState.subscribe = baseState.subscribe - return useSliceState -} diff --git a/src/create.ts b/src/create.ts deleted file mode 100644 index 2071c59..0000000 --- a/src/create.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { createEmitter } from './create-emitter' -import type { SetValue, SetterState, StateDataInternal, DefaultValue, GetterState, IsEqual, UpdateValue } from './types' -import { getDefaultValue } from './types' -import { isAbortError, isEqualBase, isObject, isPromise, isSetValueFunction } from './is' -import { createBaseState } from './create-base-state' -import { createGetterState } from './create-getter-state' -import { cancelablePromise } from './common' - -/** - * Creates a basic atom state. - * @param defaultValue - The initial state value. - * @param options - Optional settings for the state (e.g., isEqual, onSet). - * @returns A state object that can be used as a hook and provides state management methods. - * @example - * ```typescript - * // Global scope - * const counterState = state(0); - * const userState = state({ name: 'John', age: 20 }); - * - * // React component - * const counter = counterState(); // Use as a hook - * const user = userState(); - * - * // Access partial data from the state using slice - * const userAge = userState.slice((state) => state.age)(); - * ``` - */ - -export function create(defaultValue: DefaultValue, isEqual: IsEqual = isEqualBase): SetterState> { - function resolveSetter(value: T, stateSetter: SetValue): T { - if (isSetValueFunction(stateSetter)) { - return stateSetter(value) - } - return stateSetter - } - - const stateData: StateDataInternal = { - updateVersion: 0, - value: undefined, - } - - function getValue(): T { - if (stateData.value === undefined) { - stateData.value = getDefaultValue(defaultValue) - } - return stateData.value - } - - function get(): T { - const stateValue = getValue() - if (isPromise(stateValue)) { - const { controller, promise } = cancelablePromise(stateValue, stateData.abortController) - stateData.abortController = controller - promise - .then((data) => { - stateData.value = data as Awaited - emitter.emit() - }) - .catch((error) => { - if (isAbortError(error)) { - return - } - stateData.value = new Error(error) as T - }) - } - return stateValue - } - - function set(stateValue: SetValue) { - const stateValueData = getValue() - if (stateData.abortController) { - stateData.abortController.abort() - stateData.abortController = undefined - } - - const newState = resolveSetter(stateValueData, stateValue) - const isEqualResult = isEqual?.(stateValueData, newState) - if (isEqualResult || newState === stateValueData) { - return - } - stateData.updateVersion++ - stateData.value = newState - emitter.emit() - } - - function update(stateValue: UpdateValue) { - if (isObject(stateValue)) { - return set((previousState) => { - return { ...previousState, ...stateValue } - }) - } - set(stateValue as T) - } - - const emitter = createEmitter(get) - - const baseState = createBaseState({ - emitter, - getGetterState: () => setterState, - getState: get, - reset() { - const value = getDefaultValue(defaultValue) - if (isPromise(value)) { - const { controller, promise } = cancelablePromise(value, stateData.abortController) - stateData.abortController = controller - promise - .then((data) => { - set(data as T) - }) - .catch((error) => { - if (isAbortError(error)) { - return - } - stateData.value = new Error(error) as T - }) - return - } - set(value) - }, - }) - - const getterState: GetterState = createGetterState({ baseState }) - const setterState: SetterState = getterState as SetterState - setterState.setState = set - setterState.updateState = update - return setterState as SetterState> -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 546ab72..0000000 --- a/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './types' -export { create } from './create' -export { select } from './select' -export { merge } from './merge' -export { useStateValue } from './use-state-value' -export { shallow } from './shallow' diff --git a/src/merge.ts b/src/merge.ts deleted file mode 100644 index 624ee5f..0000000 --- a/src/merge.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createBaseState } from './create-base-state' -import { createEmitter } from './create-emitter' -import { createGetterState } from './create-getter-state' -import { isEqualBase } from './is' -import type { IsEqual, GetterState } from './types' - -export function merge( - states: { [K in keyof T]: GetterState }, - selector: (...values: T) => S, - isEqual: IsEqual = isEqualBase, -): GetterState { - let previousData: S | undefined - const emitter = createEmitter(() => { - const data = selector(...(states.map((state) => state.getState()) as T)) - if (previousData !== undefined && isEqual(previousData, data)) { - return previousData - } - previousData = data - return data - }) - for (const state of states) { - state.__internal.emitter.subscribe(() => { - emitter.emit() - }) - } - - const baseState = createBaseState({ - emitter, - getGetterState: () => getterState, - getState: () => selector(...(states.map((state) => state.getState()) as T)), - reset() { - for (const state of states) state.reset() - }, - }) - - const getterState: GetterState = createGetterState({ baseState }) - return getterState -} diff --git a/src/select.ts b/src/select.ts deleted file mode 100644 index 721ea3b..0000000 --- a/src/select.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createBaseState } from './create-base-state' -import { createEmitter } from './create-emitter' -import { createGetterState } from './create-getter-state' -import { isEqualBase } from './is' -import type { IsEqual, GetterState } from './types' - -export function select( - state: GetterState, - selector: (value: T) => S, - isEqual: IsEqual = isEqualBase, -): GetterState { - let previousData: S | undefined - const emitter = createEmitter(() => { - const data = selector(state.getState()) - if (previousData !== undefined && isEqual(previousData, data)) { - return previousData - } - previousData = data - return data - }) - state.__internal.emitter.subscribe(() => { - emitter.emit() - }) - - const baseState = createBaseState({ - emitter, - getGetterState: () => getterState, - getState: () => selector(state.getState()), - reset: state.reset, - }) - const getterState: GetterState = createGetterState({ baseState }) - return getterState -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index c38fdea..0000000 --- a/src/types.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Emitter } from './create-emitter' -import { isFunction, isPromise } from './is' - -/** - * Equality check function. - */ -export type IsEqual = (a: T, b: T) => boolean - -export type Setter = (value: T) => T -/** - * Set new state value function. - */ -export type SetValue = T | Setter -export type UpdateValue = T extends object ? Partial : T - -/** - * Set new state function - */ -export type StateValue = undefined extends S ? T : S -export type Set = (value: SetValue) => void -export type Update = (value: UpdateValue) => void - -/** - * Getting state value function. - */ -export type GetState = () => T -export interface StateDataInternal { - value?: T - updateVersion: number - abortController?: AbortController -} - -// eslint-disable-next-line no-shadow -export enum StateKeys { - IS_STATE = 'isState', - IS_SLICE = 'isSlice', -} - -export interface BaseState { - /** - * Reset state to default value if it's basic atom - if it's family - it will clear all family members - */ - reset: () => void - /** - * Get current state value - */ - getState: GetState - - select: (selector: (value: T) => S, isEqual?: IsEqual) => GetterState - - /** - * Internal state data - */ - __internal: { - emitter: Emitter - } - - subscribe: (listener: (value: T) => void) => () => void -} - -export interface GetterState extends BaseState { - // use use as the function call here - (selector?: (state: T) => S, isEqual?: IsEqual): StateValue -} -export interface SetterState extends GetterState { - /** - * Set new state value - */ - setState: Set - - /** - * Set new state value - */ - updateState: Update -} - -export type State = SetterState | GetterState - -export type DefaultValue = T | (() => T) - -export function getDefaultValue(initValue: DefaultValue): T { - if (isPromise(initValue)) { - return initValue - } - if (isFunction(initValue)) { - return (initValue as () => T)() - } - return initValue -} - -export interface Ref { - current: T | undefined - readonly isRef: true -} diff --git a/src/use-state-value.ts b/src/use-state-value.ts deleted file mode 100644 index 64b99a9..0000000 --- a/src/use-state-value.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { IsEqual, State } from './types' -import { useSyncExternalStore, toType } from './common' -import { isAnyOtherError, isPromise } from './is' - -/** - * useCachedStateValue Hook. - * Hook for use state inside react scope. If the state is async - component need to be wrapped with Suspense. - * @param state - state value - * @param selector - selector function (useStateValue(state, (state) => state.value)) - it return only selected value, selector don't need to be memoized. - * @param isEqual - equality check function for selector - * @returns StateValue from selector if provided, otherwise whole state - */ -export function useStateValue( - state: State, - selector: (stateValue: T) => S = (stateValue) => toType(stateValue), - isEqual?: IsEqual, -): undefined extends S ? T : S { - const data = useSyncExternalStore( - state.__internal.emitter, - (stateValue) => { - return selector(stateValue) - }, - isEqual, - ) - if (isPromise(data)) { - throw data - } - if (isAnyOtherError(data)) { - throw data - } - return data -} diff --git a/tsconfig.json b/tsconfig.json index e1ecd2b..8bb68f2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "downlevelIteration": true, "sourceMap": false }, - "include": ["src"] + "include": ["packages/core"] } diff --git a/tsconfig.types.json b/tsconfig.types.json index 794485e..a26b355 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -7,7 +7,7 @@ "emitDeclarationOnly": false, // Generate only declaration files "skipLibCheck": true }, - "include": ["src"], + "include": ["packages/core"], // exclude tests "exclude": ["**/*.test.ts", "**/*.test.tsx"] }