diff --git a/docs/config.json b/docs/config.json index fd72cbf..f7f620e 100644 --- a/docs/config.json +++ b/docs/config.json @@ -16,6 +16,10 @@ { "label": "Installation", "to": "installation" + }, + { + "label": "Quick Start", + "to": "quick-start" } ], "frameworks": [ @@ -77,9 +81,21 @@ "label": "Classes / Store", "to": "reference/classes/store" }, + { + "label": "Classes / Derived", + "to": "reference/classes/derived" + }, + { + "label": "Classes / Effect", + "to": "reference/classes/effect" + }, { "label": "Interfaces / StoreOptions", "to": "reference/interfaces/storeoptions" + }, + { + "label": "Interfaces / DerivedOptions", + "to": "reference/interfaces/derivedoptions" } ], "frameworks": [ diff --git a/docs/framework/angular/quick-start.md b/docs/framework/angular/quick-start.md index 08537c1..454e1a5 100644 --- a/docs/framework/angular/quick-start.md +++ b/docs/framework/angular/quick-start.md @@ -3,7 +3,7 @@ title: Quick Start id: quick-start --- -The basic angular app example to get started with the Tanstack angular-store. +The basic angular app example to get started with the TanStack angular-store. **app.component.ts** ```angular-ts diff --git a/docs/framework/angular/reference/functions/injectstore.md b/docs/framework/angular/reference/functions/injectstore.md index c7d556b..aac8274 100644 --- a/docs/framework/angular/reference/functions/injectstore.md +++ b/docs/framework/angular/reference/functions/injectstore.md @@ -5,39 +5,76 @@ title: injectStore # Function: injectStore() +## Call Signature + ```ts -function injectStore( +function injectStore( store, - selector, -options): Signal + selector?, +options?): Signal ``` -## Type Parameters +### Type Parameters • **TState** • **TSelected** = `NoInfer`\<`TState`\> -• **TUpdater** *extends* `AnyUpdater` = `AnyUpdater` +### Parameters + +#### store + +`Store`\<`TState`, `any`\> + +#### selector? + +(`state`) => `TSelected` + +#### options? + +`CreateSignalOptions`\<`TSelected`\> & `object` + +### Returns + +`Signal`\<`TSelected`\> + +### Defined in + +[index.ts:19](https://github.com/TanStack/store/blob/main/packages/angular-store/src/index.ts#L19) + +## Call Signature + +```ts +function injectStore( + store, + selector?, +options?): Signal +``` + +### Type Parameters + +• **TState** + +• **TSelected** = `NoInfer`\<`TState`\> -## Parameters +### Parameters -### store +#### store -`Store`\<`TState`, `TUpdater`\> +`Derived`\<`TState`, `any`\> -### selector +#### selector? (`state`) => `TSelected` -### options +#### options? -`CreateSignalOptions`\<`TSelected`\> & `object` = `...` +`CreateSignalOptions`\<`TSelected`\> & `object` -## Returns +### Returns `Signal`\<`TSelected`\> -## Defined in +### Defined in -[index.ts:17](https://github.com/TanStack/store/blob/main/packages/angular-store/src/index.ts#L17) +[index.ts:24](https://github.com/TanStack/store/blob/main/packages/angular-store/src/index.ts#L24) diff --git a/docs/framework/react/quick-start.md b/docs/framework/react/quick-start.md index cf61e19..8674add 100644 --- a/docs/framework/react/quick-start.md +++ b/docs/framework/react/quick-start.md @@ -3,7 +3,7 @@ title: Quick Start id: quick-start --- -The basic react app example to get started with the Tanstack react-store. +The basic react app example to get started with the TanStack react-store. ```tsx import React from "react"; @@ -56,4 +56,4 @@ const root = ReactDOM.createRoot(document.getElementById("root")); root.render(); -``` \ No newline at end of file +``` diff --git a/docs/framework/react/reference/functions/shallow.md b/docs/framework/react/reference/functions/shallow.md index 3a2217c..f8c0c23 100644 --- a/docs/framework/react/reference/functions/shallow.md +++ b/docs/framework/react/reference/functions/shallow.md @@ -29,4 +29,4 @@ function shallow(objA, objB): boolean ## Defined in -[index.ts:30](https://github.com/TanStack/store/blob/main/packages/react-store/src/index.ts#L30) +[index.ts:34](https://github.com/TanStack/store/blob/main/packages/react-store/src/index.ts#L34) diff --git a/docs/framework/react/reference/functions/usestore.md b/docs/framework/react/reference/functions/usestore.md index 56dd330..2f5e09a 100644 --- a/docs/framework/react/reference/functions/usestore.md +++ b/docs/framework/react/reference/functions/usestore.md @@ -5,32 +5,62 @@ title: useStore # Function: useStore() +## Call Signature + ```ts -function useStore(store, selector): TSelected +function useStore(store, selector?): TSelected ``` -## Type Parameters +### Type Parameters • **TState** • **TSelected** = `NoInfer`\<`TState`\> -• **TUpdater** *extends* `AnyUpdater` = `AnyUpdater` - -## Parameters +### Parameters -### store +#### store -`Store`\<`TState`, `TUpdater`\> +`Store`\<`TState`, `any`\> -### selector +#### selector? (`state`) => `TSelected` -## Returns +### Returns `TSelected` -## Defined in +### Defined in [index.ts:11](https://github.com/TanStack/store/blob/main/packages/react-store/src/index.ts#L11) + +## Call Signature + +```ts +function useStore(store, selector?): TSelected +``` + +### Type Parameters + +• **TState** + +• **TSelected** = `NoInfer`\<`TState`\> + +### Parameters + +#### store + +`Derived`\<`TState`, `any`\> + +#### selector? + +(`state`) => `TSelected` + +### Returns + +`TSelected` + +### Defined in + +[index.ts:15](https://github.com/TanStack/store/blob/main/packages/react-store/src/index.ts#L15) diff --git a/docs/framework/solid/quick-start.md b/docs/framework/solid/quick-start.md index d0bd4af..c4e29bb 100644 --- a/docs/framework/solid/quick-start.md +++ b/docs/framework/solid/quick-start.md @@ -3,7 +3,7 @@ title: Quick Start id: quick-start --- -The basic Solid app example to get started with the Tanstack Solid-store. +The basic Solid app example to get started with the TanStack Solid-store. ```jsx import { useStore, Store } from '@tanstack/solid-store'; diff --git a/docs/framework/solid/reference/functions/usestore.md b/docs/framework/solid/reference/functions/usestore.md index f288e8f..e5c3086 100644 --- a/docs/framework/solid/reference/functions/usestore.md +++ b/docs/framework/solid/reference/functions/usestore.md @@ -5,32 +5,62 @@ title: useStore # Function: useStore() +## Call Signature + ```ts -function useStore(store, selector): Accessor +function useStore(store, selector?): Accessor ``` -## Type Parameters +### Type Parameters • **TState** • **TSelected** = `NoInfer`\<`TState`\> -• **TUpdater** *extends* `AnyUpdater` = `AnyUpdater` - -## Parameters +### Parameters -### store +#### store -`Store`\<`TState`, `TUpdater`\> +`Store`\<`TState`, `any`\> -### selector +#### selector? (`state`) => `TSelected` -## Returns +### Returns `Accessor`\<`TSelected`\> -## Defined in +### Defined in [index.tsx:13](https://github.com/TanStack/store/blob/main/packages/solid-store/src/index.tsx#L13) + +## Call Signature + +```ts +function useStore(store, selector?): Accessor +``` + +### Type Parameters + +• **TState** + +• **TSelected** = `NoInfer`\<`TState`\> + +### Parameters + +#### store + +`Derived`\<`TState`, `any`\> + +#### selector? + +(`state`) => `TSelected` + +### Returns + +`Accessor`\<`TSelected`\> + +### Defined in + +[index.tsx:17](https://github.com/TanStack/store/blob/main/packages/solid-store/src/index.tsx#L17) diff --git a/docs/framework/svelte/quick-start.md b/docs/framework/svelte/quick-start.md index b6682ca..39b9a76 100644 --- a/docs/framework/svelte/quick-start.md +++ b/docs/framework/svelte/quick-start.md @@ -3,7 +3,7 @@ title: Quick Start id: quick-start --- -The basic Svelte app example to get started with the Tanstack svelte-store. +The basic Svelte app example to get started with the TanStack svelte-store. **store.ts** ```ts @@ -65,4 +65,4 @@ export function updateState(animal: 'cats' | 'dogs') { -``` \ No newline at end of file +``` diff --git a/docs/framework/svelte/reference/functions/shallow.md b/docs/framework/svelte/reference/functions/shallow.md index a4af3e8..584acfb 100644 --- a/docs/framework/svelte/reference/functions/shallow.md +++ b/docs/framework/svelte/reference/functions/shallow.md @@ -29,4 +29,4 @@ function shallow(objA, objB): boolean ## Defined in -[index.svelte.ts:39](https://github.com/TanStack/store/blob/main/packages/svelte-store/src/index.svelte.ts#L39) +[index.svelte.ts:43](https://github.com/TanStack/store/blob/main/packages/svelte-store/src/index.svelte.ts#L43) diff --git a/docs/framework/svelte/reference/functions/usestore.md b/docs/framework/svelte/reference/functions/usestore.md index cd699ec..c04b6b7 100644 --- a/docs/framework/svelte/reference/functions/usestore.md +++ b/docs/framework/svelte/reference/functions/usestore.md @@ -5,44 +5,74 @@ title: useStore # Function: useStore() +## Call Signature + ```ts -function useStore(store, selector): object +function useStore(store, selector?): object ``` -## Type Parameters +### Type Parameters • **TState** • **TSelected** = `NoInfer`\<`TState`\> -• **TUpdater** *extends* `AnyUpdater` = `AnyUpdater` - -## Parameters +### Parameters -### store +#### store -`Store`\<`TState`, `TUpdater`\> +`Store`\<`TState`, `any`\> -### selector +#### selector? (`state`) => `TSelected` -## Returns +### Returns `object` -### current +#### current -#### Get Signature +```ts +readonly current: TSelected; +``` + +### Defined in + +[index.svelte.ts:10](https://github.com/TanStack/store/blob/main/packages/svelte-store/src/index.svelte.ts#L10) + +## Call Signature ```ts -get current(): TSelected +function useStore(store, selector?): object ``` -##### Returns +### Type Parameters + +• **TState** + +• **TSelected** = `NoInfer`\<`TState`\> -`TSelected` +### Parameters -## Defined in +#### store -[index.svelte.ts:10](https://github.com/TanStack/store/blob/main/packages/svelte-store/src/index.svelte.ts#L10) +`Derived`\<`TState`, `any`\> + +#### selector? + +(`state`) => `TSelected` + +### Returns + +`object` + +#### current + +```ts +readonly current: TSelected; +``` + +### Defined in + +[index.svelte.ts:14](https://github.com/TanStack/store/blob/main/packages/svelte-store/src/index.svelte.ts#L14) diff --git a/docs/framework/vue/quick-start.md b/docs/framework/vue/quick-start.md index 7764ad1..de3d989 100644 --- a/docs/framework/vue/quick-start.md +++ b/docs/framework/vue/quick-start.md @@ -3,7 +3,7 @@ title: Quick Start id: quick-start --- -The basic vue app example to get started with the Tanstack vue-store. +The basic vue app example to get started with the TanStack vue-store. **App.vue** ```html diff --git a/docs/framework/vue/reference/functions/shallow.md b/docs/framework/vue/reference/functions/shallow.md index 18cfcd9..16bc4a8 100644 --- a/docs/framework/vue/reference/functions/shallow.md +++ b/docs/framework/vue/reference/functions/shallow.md @@ -29,4 +29,4 @@ function shallow(objA, objB): boolean ## Defined in -[index.ts:43](https://github.com/TanStack/store/blob/main/packages/vue-store/src/index.ts#L43) +[index.ts:47](https://github.com/TanStack/store/blob/main/packages/vue-store/src/index.ts#L47) diff --git a/docs/framework/vue/reference/functions/usestore.md b/docs/framework/vue/reference/functions/usestore.md index 2094d90..7ec6bbc 100644 --- a/docs/framework/vue/reference/functions/usestore.md +++ b/docs/framework/vue/reference/functions/usestore.md @@ -5,32 +5,62 @@ title: useStore # Function: useStore() +## Call Signature + ```ts -function useStore(store, selector): Readonly> +function useStore(store, selector?): Readonly> ``` -## Type Parameters +### Type Parameters • **TState** • **TSelected** = `NoInfer`\<`TState`\> -• **TUpdater** *extends* `AnyUpdater` = `AnyUpdater` - -## Parameters +### Parameters -### store +#### store -`Store`\<`TState`, `TUpdater`\> +`Store`\<`TState`, `any`\> -### selector +#### selector? (`state`) => `TSelected` -## Returns +### Returns `Readonly`\<`Ref`\<`TSelected`\>\> -## Defined in +### Defined in [index.ts:12](https://github.com/TanStack/store/blob/main/packages/vue-store/src/index.ts#L12) + +## Call Signature + +```ts +function useStore(store, selector?): Readonly> +``` + +### Type Parameters + +• **TState** + +• **TSelected** = `NoInfer`\<`TState`\> + +### Parameters + +#### store + +`Derived`\<`TState`, `any`\> + +#### selector? + +(`state`) => `TSelected` + +### Returns + +`Readonly`\<`Ref`\<`TSelected`\>\> + +### Defined in + +[index.ts:16](https://github.com/TanStack/store/blob/main/packages/vue-store/src/index.ts#L16) diff --git a/docs/installation.md b/docs/installation.md index 3fff5c6..28390d0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -27,7 +27,7 @@ TanStack Store is compatible with Vue 2 and 3. npm install @tanstack/angular-store ``` -TanStack Store is compatible with Angular 16+ +TanStack Store is compatible with Angular 19+ ## SolidJS diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 0000000..c0b2daf --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,153 @@ +--- +title: Quick Start +id: quick-start +--- + +TanStack Store is, first and foremost, a framework-agnostic signals implementation. + +It can be used with any of our framework adapters, but can also be used in vanilla JavaScript or TypeScript. It's currently used to power many of our library's internals. + +# Store + +You'll start by creating a new store instance, which is a wrapper around your data: + +```typescript +import { Store } from '@tanstack/store'; + +const countStore = new Store(0); + +console.log(countStore.state); // 0 +countStore.setState(() => 1); +console.log(countStore.state); // 1 +``` + +This `Store` can then be used to track updates to your data: + +```typescript +const unsub = countStore.subscribe(() => { + console.log('The count is now:', count.state); +}); + +// Later, to cleanup +unsub(); +``` + +You can even transform the data before it's updated: + +```typescript +const count = new Store(12, { + updateFn: (prevValue) => updateValue => { + return updateValue + prevValue; + } +}); + +count.setState(() => 12); +// count.state === 24 +``` + +And implement a primitive form of derived state: + +```typescript +let double = 0; +const count = new Store(0, { + onUpdate: () => { + double = count.state * 2; + } +}) +``` + +## Batch Updates + +You can batch updates to a store by using the `batch` function: + +```typescript +import { batch } from '@tanstack/store'; + +// countStore.subscribers will only trigger once at the end with the final state +batch(() => { + countStore.setState(() => 1); + countStore.setState(() => 2); +}); +``` + +# Derived + +You can also use the `Derived` class to create derived values that lazily update when their dependencies change: + +```typescript +const count = new Store(0); + +const double = new Derived({ + fn: () => count.state * 2, + // Must explicitly list dependencies + deps: [count] +}); + +// Must mount the derived value to start listening for updates +const unmount = double.mount(); + +// Later, to cleanup +unmount(); +``` + +## Previous Deferred Value + +You can access the previous value of a derived computation by using the `prevVal` argument passed to the `fn` function: + +```typescript +const count = new Store(1); + +const double = new Derived({ + fn: ({ prevVal }) => { + return count.state + (prevVal ?? 0); + }, + deps: [count] +}); + +double.mount(); +double.state; // 1 +count.setState(() => 2); +double.state; // 3 +``` + +## Dependency Values + +You can access the values of the dependencies of a derived computation by using the `prevDepVals` and `currDepVals` arguments passed to the `fn` function: + +```typescript +const count = new Store(1); + +const double = new Derived({ + fn: ({ prevDepVals, currDepVals }) => { + return (prevDepVals[0] ?? 0) + currDepVals[0]; + }, + deps: [count] +}); + +double.mount(); +double.state; // 1 +count.setState(() => 2); +double.state; // 3 +``` + +# Effects + +You can also use the `Effect` class to manage side effects across multiple stores and derived values: + +```typescript +const effect = new Effect({ + fn: () => { + console.log('The count is now:', count.state); + }, + // Array of `Store`s or `Derived`s + deps: [count], + // Should effect run immediately, default is false + eager: true +}) + +// Must mount the effect to start listening for updates +const unmount = effect.mount(); + +// Later, to cleanup +unmount(); +``` diff --git a/docs/reference/classes/derived.md b/docs/reference/classes/derived.md new file mode 100644 index 0000000..d4f2195 --- /dev/null +++ b/docs/reference/classes/derived.md @@ -0,0 +1,250 @@ +--- +id: Derived +title: Derived +--- + +# Class: Derived\ + +## Type Parameters + +• **TState** + +• **TArr** *extends* `ReadonlyArray`\<[`Derived`](derived.md)\<`any`\> \| [`Store`](store.md)\<`any`\>\> = `ReadonlyArray`\<`any`\> + +## Constructors + +### new Derived() + +```ts +new Derived(options): Derived +``` + +#### Parameters + +##### options + +[`DerivedOptions`](../interfaces/derivedoptions.md)\<`TState`, `TArr`\> + +#### Returns + +[`Derived`](derived.md)\<`TState`, `TArr`\> + +#### Defined in + +[derived.ts:87](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L87) + +## Properties + +### lastSeenDepValues + +```ts +lastSeenDepValues: unknown[] = []; +``` + +#### Defined in + +[derived.ts:71](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L71) + +*** + +### listeners + +```ts +listeners: Set>; +``` + +#### Defined in + +[derived.ts:60](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L60) + +*** + +### options + +```ts +options: DerivedOptions; +``` + +#### Defined in + +[derived.ts:63](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L63) + +*** + +### prevState + +```ts +prevState: undefined | TState; +``` + +#### Defined in + +[derived.ts:62](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L62) + +*** + +### state + +```ts +state: TState; +``` + +#### Defined in + +[derived.ts:61](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L61) + +## Methods + +### checkIfRecalculationNeededDeeply() + +```ts +checkIfRecalculationNeededDeeply(): void +``` + +#### Returns + +`void` + +#### Defined in + +[derived.ts:157](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L157) + +*** + +### getDepVals() + +```ts +getDepVals(): object +``` + +#### Returns + +`object` + +##### currDepVals + +```ts +currDepVals: unknown[]; +``` + +##### prevDepVals + +```ts +prevDepVals: unknown[]; +``` + +##### prevVal + +```ts +prevVal: undefined | NonNullable; +``` + +#### Defined in + +[derived.ts:72](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L72) + +*** + +### mount() + +```ts +mount(): () => void +``` + +#### Returns + +`Function` + +##### Returns + +`void` + +#### Defined in + +[derived.ts:178](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L178) + +*** + +### recompute() + +```ts +recompute(): void +``` + +#### Returns + +`void` + +#### Defined in + +[derived.ts:145](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L145) + +*** + +### registerOnGraph() + +```ts +registerOnGraph(deps): void +``` + +#### Parameters + +##### deps + +readonly ([`Derived`](derived.md)\<`any`, readonly `any`[]\> \| [`Store`](store.md)\<`any`, (`cb`) => `any`\>)[] = `...` + +#### Returns + +`void` + +#### Defined in + +[derived.ts:96](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L96) + +*** + +### subscribe() + +```ts +subscribe(listener): () => void +``` + +#### Parameters + +##### listener + +`Listener`\<`TState`\> + +#### Returns + +`Function` + +##### Returns + +`void` + +#### Defined in + +[derived.ts:190](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L190) + +*** + +### unregisterFromGraph() + +```ts +unregisterFromGraph(deps): void +``` + +#### Parameters + +##### deps + +readonly ([`Derived`](derived.md)\<`any`, readonly `any`[]\> \| [`Store`](store.md)\<`any`, (`cb`) => `any`\>)[] = `...` + +#### Returns + +`void` + +#### Defined in + +[derived.ts:125](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L125) diff --git a/docs/reference/classes/effect.md b/docs/reference/classes/effect.md new file mode 100644 index 0000000..f5014a0 --- /dev/null +++ b/docs/reference/classes/effect.md @@ -0,0 +1,48 @@ +--- +id: Effect +title: Effect +--- + +# Class: Effect + +## Constructors + +### new Effect() + +```ts +new Effect(opts): Effect +``` + +#### Parameters + +##### opts + +`EffectOptions` + +#### Returns + +[`Effect`](effect.md) + +#### Defined in + +[effect.ts:23](https://github.com/TanStack/store/blob/main/packages/store/src/effect.ts#L23) + +## Methods + +### mount() + +```ts +mount(): () => void +``` + +#### Returns + +`Function` + +##### Returns + +`void` + +#### Defined in + +[effect.ts:39](https://github.com/TanStack/store/blob/main/packages/store/src/effect.ts#L39) diff --git a/docs/reference/classes/store.md b/docs/reference/classes/store.md index 982e44c..78bd29b 100644 --- a/docs/reference/classes/store.md +++ b/docs/reference/classes/store.md @@ -35,19 +35,19 @@ new Store(initialState, options?): Store #### Defined in -[index.ts:50](https://github.com/TanStack/store/blob/main/packages/store/src/index.ts#L50) +[store.ts:36](https://github.com/TanStack/store/blob/main/packages/store/src/store.ts#L36) ## Properties ### listeners ```ts -listeners: Set; +listeners: Set>; ``` #### Defined in -[index.ts:38](https://github.com/TanStack/store/blob/main/packages/store/src/index.ts#L38) +[store.ts:31](https://github.com/TanStack/store/blob/main/packages/store/src/store.ts#L31) *** @@ -59,43 +59,33 @@ optional options: StoreOptions; #### Defined in -[index.ts:40](https://github.com/TanStack/store/blob/main/packages/store/src/index.ts#L40) +[store.ts:34](https://github.com/TanStack/store/blob/main/packages/store/src/store.ts#L34) *** -### state +### prevState ```ts -state: TState; +prevState: TState; ``` #### Defined in -[index.ts:39](https://github.com/TanStack/store/blob/main/packages/store/src/index.ts#L39) +[store.ts:33](https://github.com/TanStack/store/blob/main/packages/store/src/store.ts#L33) -## Methods +*** -### batch() +### state ```ts -batch(cb): void +state: TState; ``` -#### Parameters - -##### cb - -() => `void` - -#### Returns - -`void` - #### Defined in -[index.ts:89](https://github.com/TanStack/store/blob/main/packages/store/src/index.ts#L89) +[store.ts:32](https://github.com/TanStack/store/blob/main/packages/store/src/store.ts#L32) -*** +## Methods ### setState() @@ -115,7 +105,7 @@ setState(updater): void #### Defined in -[index.ts:64](https://github.com/TanStack/store/blob/main/packages/store/src/index.ts#L64) +[store.ts:51](https://github.com/TanStack/store/blob/main/packages/store/src/store.ts#L51) *** @@ -129,7 +119,7 @@ subscribe(listener): () => void ##### listener -`Listener` +`Listener`\<`TState`\> #### Returns @@ -141,4 +131,4 @@ subscribe(listener): () => void #### Defined in -[index.ts:55](https://github.com/TanStack/store/blob/main/packages/store/src/index.ts#L55) +[store.ts:42](https://github.com/TanStack/store/blob/main/packages/store/src/store.ts#L42) diff --git a/docs/reference/functions/batch.md b/docs/reference/functions/batch.md new file mode 100644 index 0000000..203c051 --- /dev/null +++ b/docs/reference/functions/batch.md @@ -0,0 +1,24 @@ +--- +id: batch +title: batch +--- + +# Function: batch() + +```ts +function batch(fn): void +``` + +## Parameters + +### fn + +() => `void` + +## Returns + +`void` + +## Defined in + +[scheduler.ts:140](https://github.com/TanStack/store/blob/main/packages/store/src/scheduler.ts#L140) diff --git a/docs/reference/index.md b/docs/reference/index.md index 1ad16e4..720551c 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -7,8 +7,26 @@ title: "@tanstack/store" ## Classes +- [Derived](classes/derived.md) +- [Effect](classes/effect.md) - [Store](classes/store.md) ## Interfaces +- [DerivedFnProps](interfaces/derivedfnprops.md) +- [DerivedOptions](interfaces/derivedoptions.md) - [StoreOptions](interfaces/storeoptions.md) + +## Type Aliases + +- [UnwrapDerivedOrStore](type-aliases/unwrapderivedorstore.md) + +## Variables + +- [\_\_depsThatHaveWrittenThisTick](variables/depsthathavewrittenthistick.md) +- [\_\_derivedToStore](variables/derivedtostore.md) +- [\_\_storeToDerived](variables/storetoderived.md) + +## Functions + +- [batch](functions/batch.md) diff --git a/docs/reference/interfaces/derivedfnprops.md b/docs/reference/interfaces/derivedfnprops.md new file mode 100644 index 0000000..721c204 --- /dev/null +++ b/docs/reference/interfaces/derivedfnprops.md @@ -0,0 +1,50 @@ +--- +id: DerivedFnProps +title: DerivedFnProps +--- + +# Interface: DerivedFnProps\ + +## Type Parameters + +• **TArr** *extends* `ReadonlyArray`\<[`Derived`](../classes/derived.md)\<`any`\> \| [`Store`](../classes/store.md)\<`any`\>\> = `ReadonlyArray`\<`any`\> + +• **TUnwrappedArr** *extends* `UnwrapReadonlyDerivedOrStoreArray`\<`TArr`\> = `UnwrapReadonlyDerivedOrStoreArray`\<`TArr`\> + +## Properties + +### currDepVals + +```ts +currDepVals: TUnwrappedArr; +``` + +#### Defined in + +[derived.ts:35](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L35) + +*** + +### prevDepVals + +```ts +prevDepVals: undefined | TUnwrappedArr; +``` + +#### Defined in + +[derived.ts:34](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L34) + +*** + +### prevVal + +```ts +prevVal: unknown; +``` + +`undefined` if it's the first run + +#### Defined in + +[derived.ts:33](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L33) diff --git a/docs/reference/interfaces/derivedoptions.md b/docs/reference/interfaces/derivedoptions.md new file mode 100644 index 0000000..c1fa294 --- /dev/null +++ b/docs/reference/interfaces/derivedoptions.md @@ -0,0 +1,94 @@ +--- +id: DerivedOptions +title: DerivedOptions +--- + +# Interface: DerivedOptions\ + +## Type Parameters + +• **TState** + +• **TArr** *extends* `ReadonlyArray`\<[`Derived`](../classes/derived.md)\<`any`\> \| [`Store`](../classes/store.md)\<`any`\>\> = `ReadonlyArray`\<`any`\> + +## Properties + +### deps + +```ts +deps: TArr; +``` + +#### Defined in + +[derived.ts:47](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L47) + +*** + +### fn() + +```ts +fn: (props) => TState; +``` + +Values of the `deps` from before and after the current invocation of `fn` + +#### Parameters + +##### props + +[`DerivedFnProps`](derivedfnprops.md)\<`TArr`, `UnwrapReadonlyDerivedOrStoreArray`\<`TArr`\>\> + +#### Returns + +`TState` + +#### Defined in + +[derived.ts:51](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L51) + +*** + +### onSubscribe()? + +```ts +optional onSubscribe: (listener, derived) => () => void; +``` + +#### Parameters + +##### listener + +`Listener`\<`TState`\> + +##### derived + +[`Derived`](../classes/derived.md)\<`TState`, readonly `any`[]\> + +#### Returns + +`Function` + +##### Returns + +`void` + +#### Defined in + +[derived.ts:42](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L42) + +*** + +### onUpdate()? + +```ts +optional onUpdate: () => void; +``` + +#### Returns + +`void` + +#### Defined in + +[derived.ts:46](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L46) diff --git a/docs/reference/interfaces/storeoptions.md b/docs/reference/interfaces/storeoptions.md index 81781c5..0968926 100644 --- a/docs/reference/interfaces/storeoptions.md +++ b/docs/reference/interfaces/storeoptions.md @@ -25,7 +25,7 @@ Called when a listener subscribes to the store. ##### listener -`Listener` +`Listener`\<`TState`\> ##### store @@ -43,7 +43,7 @@ a function to unsubscribe the listener #### Defined in -[index.ts:24](https://github.com/TanStack/store/blob/main/packages/store/src/index.ts#L24) +[store.ts:17](https://github.com/TanStack/store/blob/main/packages/store/src/store.ts#L17) *** @@ -61,7 +61,7 @@ Called after the state has been updated, used to derive other state. #### Defined in -[index.ts:31](https://github.com/TanStack/store/blob/main/packages/store/src/index.ts#L31) +[store.ts:24](https://github.com/TanStack/store/blob/main/packages/store/src/store.ts#L24) *** @@ -95,4 +95,4 @@ Replace the default update function with a custom one. #### Defined in -[index.ts:18](https://github.com/TanStack/store/blob/main/packages/store/src/index.ts#L18) +[store.ts:11](https://github.com/TanStack/store/blob/main/packages/store/src/store.ts#L11) diff --git a/docs/reference/type-aliases/unwrapderivedorstore.md b/docs/reference/type-aliases/unwrapderivedorstore.md new file mode 100644 index 0000000..6d41771 --- /dev/null +++ b/docs/reference/type-aliases/unwrapderivedorstore.md @@ -0,0 +1,18 @@ +--- +id: UnwrapDerivedOrStore +title: UnwrapDerivedOrStore +--- + +# Type Alias: UnwrapDerivedOrStore\ + +```ts +type UnwrapDerivedOrStore: T extends Derived ? InnerD : T extends Store ? InnerS : never; +``` + +## Type Parameters + +• **T** + +## Defined in + +[derived.ts:5](https://github.com/TanStack/store/blob/main/packages/store/src/derived.ts#L5) diff --git a/docs/reference/variables/depsthathavewrittenthistick.md b/docs/reference/variables/depsthathavewrittenthistick.md new file mode 100644 index 0000000..d35dfed --- /dev/null +++ b/docs/reference/variables/depsthathavewrittenthistick.md @@ -0,0 +1,22 @@ +--- +id: __depsThatHaveWrittenThisTick +title: __depsThatHaveWrittenThisTick +--- + +# Variable: \_\_depsThatHaveWrittenThisTick + +```ts +const __depsThatHaveWrittenThisTick: object; +``` + +## Type declaration + +### current + +```ts +current: (Derived | Store unknown>)[]; +``` + +## Defined in + +[scheduler.ts:28](https://github.com/TanStack/store/blob/main/packages/store/src/scheduler.ts#L28) diff --git a/docs/reference/variables/derivedtostore.md b/docs/reference/variables/derivedtostore.md new file mode 100644 index 0000000..27cd548 --- /dev/null +++ b/docs/reference/variables/derivedtostore.md @@ -0,0 +1,14 @@ +--- +id: __derivedToStore +title: __derivedToStore +--- + +# Variable: \_\_derivedToStore + +```ts +const __derivedToStore: WeakMap, Set unknown>>>; +``` + +## Defined in + +[scheduler.ts:23](https://github.com/TanStack/store/blob/main/packages/store/src/scheduler.ts#L23) diff --git a/docs/reference/variables/storetoderived.md b/docs/reference/variables/storetoderived.md new file mode 100644 index 0000000..ba5abc0 --- /dev/null +++ b/docs/reference/variables/storetoderived.md @@ -0,0 +1,28 @@ +--- +id: __storeToDerived +title: __storeToDerived +--- + +# Variable: \_\_storeToDerived + +```ts +const __storeToDerived: WeakMap unknown>, Set>>; +``` + +This is here to solve the pyramid dependency problem where: + A + / \ + B C + \ / + D + +Where we deeply traverse this tree, how do we avoid D being recomputed twice; once when B is updated, once when C is. + +To solve this, we create linkedDeps that allows us to sync avoid writes to the state until all of the deps have been +resolved. + +This is a record of stores, because derived stores are not able to write values to, but stores are + +## Defined in + +[scheduler.ts:19](https://github.com/TanStack/store/blob/main/packages/store/src/scheduler.ts#L19) diff --git a/knip.json b/knip.json index 75bc3d9..2bcf1cb 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,18 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "workspaces": { + "packages/angular-store": { + "ignoreDependencies": ["@angular/compiler-cli"] + }, + "packages/store": { + "ignore": ["src/tests/derived.bench.ts"], + "ignoreDependencies": [ + "@angular/core", + "@preact/signals", + "solid-js", + "vue" + ] + }, "packages/vue-store": { "ignoreDependencies": ["vue2", "vue2.7"] } diff --git a/package.json b/package.json index 2a9a49d..ca99256 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "sherif": "^1.1.1", "solid-js": "^1.9.3", "typescript": "5.6.3", - "typescript49": "npm:typescript@4.9", "typescript50": "npm:typescript@5.0", "typescript51": "npm:typescript@5.1", "typescript52": "npm:typescript@5.2", diff --git a/packages/angular-store/src/index.ts b/packages/angular-store/src/index.ts index f31e019..af0ad0c 100644 --- a/packages/angular-store/src/index.ts +++ b/packages/angular-store/src/index.ts @@ -6,25 +6,33 @@ import { linkedSignal, runInInjectionContext, } from '@angular/core' -import type { AnyUpdater, Store } from '@tanstack/store' -import type { CreateSignalOptions } from '@angular/core' +import type { Derived, Store } from '@tanstack/store' +import type { CreateSignalOptions, Signal } from '@angular/core' + +export * from '@tanstack/store' /** * @private */ type NoInfer = [T][T extends any ? 0 : never] -export function injectStore< - TState, - TSelected = NoInfer, - TUpdater extends AnyUpdater = AnyUpdater, ->( - store: Store, +export function injectStore>( + store: Store, + selector?: (state: NoInfer) => TSelected, + options?: CreateSignalOptions & { injector?: Injector }, +): Signal +export function injectStore>( + store: Derived, + selector?: (state: NoInfer) => TSelected, + options?: CreateSignalOptions & { injector?: Injector }, +): Signal +export function injectStore>( + store: Store | Derived, selector: (state: NoInfer) => TSelected = (d) => d as TSelected, options: CreateSignalOptions & { injector?: Injector } = { equal: shallow, }, -) { +): Signal { !options.injector && assertInInjectionContext(injectStore) if (!options.injector) { diff --git a/packages/angular-store/tests/test.test-d.ts b/packages/angular-store/tests/test.test-d.ts new file mode 100644 index 0000000..414ae52 --- /dev/null +++ b/packages/angular-store/tests/test.test-d.ts @@ -0,0 +1,20 @@ +import { expectTypeOf, test } from 'vitest' +import { Derived, Store, injectStore } from '../src' +import type { Signal } from '@angular/core' + +test('injectStore works with derived state', () => { + const store = new Store(12) + const derived = new Derived({ + deps: [store], + fn: () => { + return { val: store.state * 2 } + }, + }) + + const val = injectStore(derived, (state) => { + expectTypeOf(state).toMatchTypeOf<{ val: number }>() + return state.val + }) + + expectTypeOf(val).toMatchTypeOf>() +}) diff --git a/packages/react-store/package.json b/packages/react-store/package.json index 6ab5ed4..f197373 100644 --- a/packages/react-store/package.json +++ b/packages/react-store/package.json @@ -23,7 +23,6 @@ "clean": "rimraf ./dist && rimraf ./coverage", "test:eslint": "eslint ./src ./tests", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", - "test:types:ts49": "node ../../node_modules/typescript49/lib/tsc.js -p tsconfig.legacy.json", "test:types:ts50": "node ../../node_modules/typescript50/lib/tsc.js", "test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js", "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js", diff --git a/packages/react-store/src/index.ts b/packages/react-store/src/index.ts index 5d0b8ac..155b0f5 100644 --- a/packages/react-store/src/index.ts +++ b/packages/react-store/src/index.ts @@ -1,5 +1,5 @@ import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector.js' -import type { AnyUpdater, Store } from '@tanstack/store' +import type { Derived, Store } from '@tanstack/store' export * from '@tanstack/store' @@ -8,14 +8,18 @@ export * from '@tanstack/store' */ export type NoInfer = [T][T extends any ? 0 : never] -export function useStore< - TState, - TSelected = NoInfer, - TUpdater extends AnyUpdater = AnyUpdater, ->( - store: Store, +export function useStore>( + store: Store, + selector?: (state: NoInfer) => TSelected, +): TSelected +export function useStore>( + store: Derived, + selector?: (state: NoInfer) => TSelected, +): TSelected +export function useStore>( + store: Store | Derived, selector: (state: NoInfer) => TSelected = (d) => d as any, -) { +): TSelected { const slice = useSyncExternalStoreWithSelector( store.subscribe, () => store.state, diff --git a/packages/react-store/tests/index.test.tsx b/packages/react-store/tests/index.test.tsx index 7aead5d..29f4f8f 100644 --- a/packages/react-store/tests/index.test.tsx +++ b/packages/react-store/tests/index.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, test, vi } from 'vitest' import { render, waitFor } from '@testing-library/react' import * as React from 'react' -import { Store } from '@tanstack/store' +import { Derived, Store } from '@tanstack/store' import { useState } from 'react' import { userEvent } from '@testing-library/user-event' import { shallow, useStore } from '../src/index' @@ -78,6 +78,39 @@ describe('useStore', () => { await user.click(getByText('Update ignored')) expect(getByText('Number rendered: 2')).toBeInTheDocument() }) + + it('works with mounted derived stores', async () => { + const store = new Store(0) + + const derived = new Derived({ + deps: [store], + fn: () => { + return { val: store.state * 2 } + }, + }) + + derived.mount() + + function Comp() { + const derivedVal = useStore(derived, (state) => state.val) + + return ( +
+

Derived: {derivedVal}

+ +
+ ) + } + + const { getByText } = render() + expect(getByText('Derived: 0')).toBeInTheDocument() + + await user.click(getByText('Update select')) + + await waitFor(() => expect(getByText('Derived: 2')).toBeInTheDocument()) + }) }) describe('shallow', () => { diff --git a/packages/react-store/tests/test.test-d.ts b/packages/react-store/tests/test.test-d.ts new file mode 100644 index 0000000..24093bd --- /dev/null +++ b/packages/react-store/tests/test.test-d.ts @@ -0,0 +1,19 @@ +import { expectTypeOf, test } from 'vitest' +import { Derived, Store, useStore } from '../src' + +test('useStore works with derived state', () => { + const store = new Store(12) + const derived = new Derived({ + deps: [store], + fn: () => { + return { val: store.state * 2 } + }, + }) + + const val = useStore(derived, (state) => { + expectTypeOf(state).toMatchTypeOf<{ val: number }>() + return state.val + }) + + expectTypeOf(val).toMatchTypeOf() +}) diff --git a/packages/react-store/tsconfig.legacy.json b/packages/react-store/tsconfig.legacy.json deleted file mode 100644 index bfa4367..0000000 --- a/packages/react-store/tsconfig.legacy.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "jsx": "react", - "paths": { - "@tanstack/store": ["../store/src"] - } - }, - "include": ["src"] -} diff --git a/packages/solid-store/package.json b/packages/solid-store/package.json index 3f054a7..677dc57 100644 --- a/packages/solid-store/package.json +++ b/packages/solid-store/package.json @@ -23,7 +23,6 @@ "clean": "rimraf ./dist && rimraf ./coverage", "test:eslint": "eslint ./src ./tests", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", - "test:types:ts49": "node ../../node_modules/typescript49/lib/tsc.js -p tsconfig.legacy.json", "test:types:ts50": "node ../../node_modules/typescript50/lib/tsc.js", "test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js", "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js", diff --git a/packages/solid-store/src/index.tsx b/packages/solid-store/src/index.tsx index 7b82b73..3897e53 100644 --- a/packages/solid-store/src/index.tsx +++ b/packages/solid-store/src/index.tsx @@ -1,6 +1,6 @@ import { onCleanup } from 'solid-js' import { createStore, reconcile } from 'solid-js/store' -import type { AnyUpdater, Store } from '@tanstack/store' +import type { Derived, Store } from '@tanstack/store' import type { Accessor } from 'solid-js' export * from '@tanstack/store' @@ -10,12 +10,16 @@ export * from '@tanstack/store' */ export type NoInfer = [T][T extends any ? 0 : never] -export function useStore< - TState, - TSelected = NoInfer, - TUpdater extends AnyUpdater = AnyUpdater, ->( - store: Store, +export function useStore>( + store: Store, + selector?: (state: NoInfer) => TSelected, +): Accessor +export function useStore>( + store: Derived, + selector?: (state: NoInfer) => TSelected, +): Accessor +export function useStore>( + store: Store | Derived, selector: (state: NoInfer) => TSelected = (d) => d as any, ): Accessor { const [slice, setSlice] = createStore({ diff --git a/packages/solid-store/tests/test.test-d.ts b/packages/solid-store/tests/test.test-d.ts new file mode 100644 index 0000000..d02e4ab --- /dev/null +++ b/packages/solid-store/tests/test.test-d.ts @@ -0,0 +1,20 @@ +import { expectTypeOf, test } from 'vitest' +import { Derived, Store, useStore } from '../src' +import type { Accessor } from 'solid-js' + +test('useStore works with derived state', () => { + const store = new Store(12) + const derived = new Derived({ + deps: [store], + fn: () => { + return { val: store.state * 2 } + }, + }) + + const val = useStore(derived, (state) => { + expectTypeOf(state).toMatchTypeOf<{ val: number }>() + return state.val + }) + + expectTypeOf(val).toMatchTypeOf>() +}) diff --git a/packages/solid-store/tsconfig.legacy.json b/packages/solid-store/tsconfig.legacy.json deleted file mode 100644 index 783ab9e..0000000 --- a/packages/solid-store/tsconfig.legacy.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "jsx": "preserve", - "jsxImportSource": "solid-js", - "paths": { - "@tanstack/store": ["../store/src"] - } - }, - "include": ["src"] -} diff --git a/packages/store/package.json b/packages/store/package.json index 3664e4c..f7883ea 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -6,7 +6,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/TanStack/store.git", + "url": "git+https://github.com/TanStack/store.git", "directory": "packages/store" }, "homepage": "https://tanstack.com/store", @@ -22,13 +22,13 @@ "clean": "rimraf ./dist && rimraf ./coverage", "test:eslint": "eslint ./src ./tests", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", - "test:types:ts49": "node ../../node_modules/typescript49/lib/tsc.js -p tsconfig.legacy.json", "test:types:ts50": "node ../../node_modules/typescript50/lib/tsc.js", "test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js", "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js", "test:types:ts53": "node ../../node_modules/typescript53/lib/tsc.js", "test:types:ts54": "tsc", "test:lib": "vitest", + "test:bench": "vitest bench", "test:lib:dev": "pnpm run test:lib --watch", "test:build": "publint --strict", "build": "vite build" @@ -54,5 +54,11 @@ "files": [ "dist", "src" - ] + ], + "devDependencies": { + "@angular/core": "^19.0.5", + "@preact/signals": "^1.3.0", + "solid-js": "^1.9.3", + "vue": "^3.5.13" + } } diff --git a/packages/store/src/derived.ts b/packages/store/src/derived.ts new file mode 100644 index 0000000..07b2542 --- /dev/null +++ b/packages/store/src/derived.ts @@ -0,0 +1,198 @@ +import { Store } from './store' +import { __derivedToStore, __storeToDerived } from './scheduler' +import type { Listener } from './types' + +export type UnwrapDerivedOrStore = + T extends Derived + ? InnerD + : T extends Store + ? InnerS + : never + +type UnwrapReadonlyDerivedOrStoreArray< + TArr extends ReadonlyArray | Store>, +> = TArr extends readonly [infer Head, ...infer Tail] + ? Head extends Derived | Store + ? Tail extends ReadonlyArray | Store> + ? [UnwrapDerivedOrStore, ...UnwrapReadonlyDerivedOrStoreArray] + : [] + : [] + : [] + +// Can't have currVal, as it's being evaluated from the current derived fn +export interface DerivedFnProps< + TArr extends ReadonlyArray | Store> = ReadonlyArray, + TUnwrappedArr extends + UnwrapReadonlyDerivedOrStoreArray = UnwrapReadonlyDerivedOrStoreArray, +> { + // `undefined` if it's the first run + /** + * `undefined` if it's the first run + * @privateRemarks this also cannot be typed as TState, as it breaks the inferencing of the function's return type when an argument is used - even with `NoInfer` usage + */ + prevVal: unknown | undefined + prevDepVals: TUnwrappedArr | undefined + currDepVals: TUnwrappedArr +} + +export interface DerivedOptions< + TState, + TArr extends ReadonlyArray | Store> = ReadonlyArray, +> { + onSubscribe?: ( + listener: Listener, + derived: Derived, + ) => () => void + onUpdate?: () => void + deps: TArr + /** + * Values of the `deps` from before and after the current invocation of `fn` + */ + fn: (props: DerivedFnProps) => TState +} + +export class Derived< + TState, + const TArr extends ReadonlyArray< + Derived | Store + > = ReadonlyArray, +> { + listeners = new Set>() + state: TState + prevState: TState | undefined + options: DerivedOptions + + /** + * Functions representing the subscriptions. Call a function to cleanup + * @private + */ + _subscriptions: Array<() => void> = [] + + lastSeenDepValues: Array = [] + getDepVals = () => { + const prevDepVals = [] as Array + const currDepVals = [] as Array + for (const dep of this.options.deps) { + prevDepVals.push(dep.prevState) + currDepVals.push(dep.state) + } + this.lastSeenDepValues = currDepVals + return { + prevDepVals, + currDepVals, + prevVal: this.prevState ?? undefined, + } + } + + constructor(options: DerivedOptions) { + this.options = options + this.state = options.fn({ + prevDepVals: undefined, + prevVal: undefined, + currDepVals: this.getDepVals().currDepVals as never, + }) + } + + registerOnGraph( + deps: ReadonlyArray | Store> = this.options.deps, + ) { + for (const dep of deps) { + if (dep instanceof Derived) { + // First register the intermediate derived value if it's not already registered + dep.registerOnGraph() + // Then register this derived with the dep's underlying stores + this.registerOnGraph(dep.options.deps) + } else if (dep instanceof Store) { + // Register the derived as related derived to the store + let relatedLinkedDerivedVals = __storeToDerived.get(dep) + if (!relatedLinkedDerivedVals) { + relatedLinkedDerivedVals = new Set() + __storeToDerived.set(dep, relatedLinkedDerivedVals) + } + relatedLinkedDerivedVals.add(this as never) + + // Register the store as a related store to this derived + let relatedStores = __derivedToStore.get(this as never) + if (!relatedStores) { + relatedStores = new Set() + __derivedToStore.set(this as never, relatedStores) + } + relatedStores.add(dep) + } + } + } + + unregisterFromGraph( + deps: ReadonlyArray | Store> = this.options.deps, + ) { + for (const dep of deps) { + if (dep instanceof Derived) { + this.unregisterFromGraph(dep.options.deps) + } else if (dep instanceof Store) { + const relatedLinkedDerivedVals = __storeToDerived.get(dep) + if (relatedLinkedDerivedVals) { + relatedLinkedDerivedVals.delete(this as never) + } + + const relatedStores = __derivedToStore.get(this as never) + if (relatedStores) { + relatedStores.delete(dep) + } + } + } + } + + recompute = () => { + this.prevState = this.state + const { prevDepVals, currDepVals, prevVal } = this.getDepVals() + this.state = this.options.fn({ + prevDepVals: prevDepVals as never, + currDepVals: currDepVals as never, + prevVal, + }) + + this.options.onUpdate?.() + } + + checkIfRecalculationNeededDeeply = () => { + for (const dep of this.options.deps) { + if (dep instanceof Derived) { + dep.checkIfRecalculationNeededDeeply() + } + } + let shouldRecompute = false + const lastSeenDepValues = this.lastSeenDepValues + const { currDepVals } = this.getDepVals() + for (let i = 0; i < currDepVals.length; i++) { + if (currDepVals[i] !== lastSeenDepValues[i]) { + shouldRecompute = true + break + } + } + + if (shouldRecompute) { + this.recompute() + } + } + + mount = () => { + this.registerOnGraph() + this.checkIfRecalculationNeededDeeply() + + return () => { + this.unregisterFromGraph() + for (const cleanup of this._subscriptions) { + cleanup() + } + } + } + + subscribe = (listener: Listener) => { + this.listeners.add(listener) + const unsub = this.options.onSubscribe?.(listener, this) + return () => { + this.listeners.delete(listener) + unsub?.() + } + } +} diff --git a/packages/store/src/effect.ts b/packages/store/src/effect.ts new file mode 100644 index 0000000..cbb647c --- /dev/null +++ b/packages/store/src/effect.ts @@ -0,0 +1,42 @@ +import { Derived } from './derived' +import type { DerivedOptions } from './derived' + +interface EffectOptions + extends Omit< + DerivedOptions, + 'onUpdate' | 'onSubscribe' | 'lazy' | 'fn' + > { + /** + * Should the effect trigger immediately? + * @default false + */ + eager?: boolean + fn: () => void +} + +export class Effect { + /** + * @private + */ + _derived: Derived + + constructor(opts: EffectOptions) { + const { eager, fn, ...derivedProps } = opts + + this._derived = new Derived({ + ...derivedProps, + fn: () => {}, + onUpdate() { + fn() + }, + }) + + if (eager) { + fn() + } + } + + mount() { + return this._derived.mount() + } +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index aa0c045..7b106f5 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,96 +1,5 @@ -/** - * @private - */ -export type AnyUpdater = (...args: Array) => any - -/** - * @private - */ -export type Listener = () => void - -export interface StoreOptions< - TState, - TUpdater extends AnyUpdater = (cb: TState) => TState, -> { - /** - * Replace the default update function with a custom one. - */ - updateFn?: (previous: TState) => (updater: TUpdater) => TState - /** - * Called when a listener subscribes to the store. - * - * @return a function to unsubscribe the listener - */ - onSubscribe?: ( - listener: Listener, - store: Store, - ) => () => void - /** - * Called after the state has been updated, used to derive other state. - */ - onUpdate?: () => void -} - -export class Store< - TState, - TUpdater extends AnyUpdater = (cb: TState) => TState, -> { - listeners = new Set() - state: TState - options?: StoreOptions - /** - * @private - */ - _batching = false - /** - * @private - */ - _flushing = 0 - - constructor(initialState: TState, options?: StoreOptions) { - this.state = initialState - this.options = options - } - - subscribe = (listener: Listener) => { - this.listeners.add(listener) - const unsub = this.options?.onSubscribe?.(listener, this) - return () => { - this.listeners.delete(listener) - unsub?.() - } - } - - setState = (updater: TUpdater) => { - const previous = this.state - this.state = this.options?.updateFn - ? this.options.updateFn(previous)(updater) - : (updater as any)(previous) - - // Always run onUpdate, regardless of batching - this.options?.onUpdate?.() - - // Attempt to flush - this._flush() - } - - /** - * @private - */ - _flush = () => { - if (this._batching) return - const flushId = ++this._flushing - this.listeners.forEach((listener) => { - if (this._flushing !== flushId) return - listener() - }) - } - - batch = (cb: () => void) => { - if (this._batching) return cb() - this._batching = true - cb() - this._batching = false - this._flush() - } -} +export * from './derived' +export * from './effect' +export * from './store' +export * from './types' +export * from './scheduler' diff --git a/packages/store/src/scheduler.ts b/packages/store/src/scheduler.ts new file mode 100644 index 0000000..f30399b --- /dev/null +++ b/packages/store/src/scheduler.ts @@ -0,0 +1,155 @@ +import { Derived } from './derived' +import type { Store } from './store' + +/** + * This is here to solve the pyramid dependency problem where: + * A + * / \ + * B C + * \ / + * D + * + * Where we deeply traverse this tree, how do we avoid D being recomputed twice; once when B is updated, once when C is. + * + * To solve this, we create linkedDeps that allows us to sync avoid writes to the state until all of the deps have been + * resolved. + * + * This is a record of stores, because derived stores are not able to write values to, but stores are + */ +export const __storeToDerived = new WeakMap< + Store, + Set> +>() +export const __derivedToStore = new WeakMap< + Derived, + Set> +>() + +export const __depsThatHaveWrittenThisTick = { + current: [] as Array | Store>, +} + +let __isFlushing = false +let __batchDepth = 0 +const __pendingUpdates = new Set>() +// Add a map to store initial values before batch +const __initialBatchValues = new Map, unknown>() + +function __flush_internals(relatedVals: Set>) { + // First sort deriveds by dependency order + const sorted = Array.from(relatedVals).sort((a, b) => { + // If a depends on b, b should go first + if (a instanceof Derived && a.options.deps.includes(b)) return 1 + // If b depends on a, a should go first + if (b instanceof Derived && b.options.deps.includes(a)) return -1 + return 0 + }) + + for (const derived of sorted) { + if (__depsThatHaveWrittenThisTick.current.includes(derived)) { + continue + } + + __depsThatHaveWrittenThisTick.current.push(derived) + derived.recompute() + + const stores = __derivedToStore.get(derived) + if (stores) { + for (const store of stores) { + const relatedLinkedDerivedVals = __storeToDerived.get(store) + if (!relatedLinkedDerivedVals) continue + __flush_internals(relatedLinkedDerivedVals) + } + } + } +} + +function __notifyListeners(store: Store) { + store.listeners.forEach((listener) => + listener({ + prevVal: store.prevState as never, + currentVal: store.state as never, + }), + ) +} + +function __notifyDerivedListeners(derived: Derived) { + derived.listeners.forEach((listener) => + listener({ + prevVal: derived.prevState as never, + currentVal: derived.state as never, + }), + ) +} + +/** + * @private only to be called from `Store` on write + */ +export function __flush(store: Store) { + // If we're starting a batch, store the initial values + if (__batchDepth > 0 && !__initialBatchValues.has(store)) { + __initialBatchValues.set(store, store.prevState) + } + + __pendingUpdates.add(store) + + if (__batchDepth > 0) return + if (__isFlushing) return + + try { + __isFlushing = true + + while (__pendingUpdates.size > 0) { + const stores = Array.from(__pendingUpdates) + __pendingUpdates.clear() + + // First notify listeners with updated values + for (const store of stores) { + // Use initial batch values for prevState if we have them + const prevState = __initialBatchValues.get(store) ?? store.prevState + store.prevState = prevState + __notifyListeners(store) + } + + // Then update all derived values + for (const store of stores) { + const derivedVals = __storeToDerived.get(store) + if (!derivedVals) continue + + __depsThatHaveWrittenThisTick.current.push(store) + __flush_internals(derivedVals) + } + + // Notify derived listeners after recomputing + for (const store of stores) { + const derivedVals = __storeToDerived.get(store) + if (!derivedVals) continue + + for (const derived of derivedVals) { + __notifyDerivedListeners(derived) + } + } + } + } finally { + __isFlushing = false + __depsThatHaveWrittenThisTick.current = [] + __initialBatchValues.clear() + } +} + +export function batch(fn: () => void) { + __batchDepth++ + try { + fn() + } finally { + __batchDepth-- + if (__batchDepth === 0) { + const pendingUpdateToFlush = Array.from(__pendingUpdates)[0] as + | Store + | undefined + if (pendingUpdateToFlush) { + __flush(pendingUpdateToFlush) // Trigger flush of all pending updates + } + } + } +} diff --git a/packages/store/src/store.ts b/packages/store/src/store.ts new file mode 100644 index 0000000..078af36 --- /dev/null +++ b/packages/store/src/store.ts @@ -0,0 +1,63 @@ +import { __flush } from './scheduler' +import type { AnyUpdater, Listener } from './types' + +export interface StoreOptions< + TState, + TUpdater extends AnyUpdater = (cb: TState) => TState, +> { + /** + * Replace the default update function with a custom one. + */ + updateFn?: (previous: TState) => (updater: TUpdater) => TState + /** + * Called when a listener subscribes to the store. + * + * @return a function to unsubscribe the listener + */ + onSubscribe?: ( + listener: Listener, + store: Store, + ) => () => void + /** + * Called after the state has been updated, used to derive other state. + */ + onUpdate?: () => void +} + +export class Store< + TState, + TUpdater extends AnyUpdater = (cb: TState) => TState, +> { + listeners = new Set>() + state: TState + prevState: TState + options?: StoreOptions + + constructor(initialState: TState, options?: StoreOptions) { + this.prevState = initialState + this.state = initialState + this.options = options + } + + subscribe = (listener: Listener) => { + this.listeners.add(listener) + const unsub = this.options?.onSubscribe?.(listener, this) + return () => { + this.listeners.delete(listener) + unsub?.() + } + } + + setState = (updater: TUpdater) => { + this.prevState = this.state + this.state = this.options?.updateFn + ? this.options.updateFn(this.prevState)(updater) + : (updater as any)(this.prevState) + + // Always run onUpdate, regardless of batching + this.options?.onUpdate?.() + + // Attempt to flush + __flush(this as never) + } +} diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts new file mode 100644 index 0000000..836ae5b --- /dev/null +++ b/packages/store/src/types.ts @@ -0,0 +1,17 @@ +/** + * @private + */ +export type AnyUpdater = (prev: any) => any + +/** + * @private + */ +export interface ListenerValue { + prevVal: T + currentVal: T +} + +/** + * @private + */ +export type Listener = (value: ListenerValue) => void diff --git a/packages/store/tests/derived.bench.ts b/packages/store/tests/derived.bench.ts new file mode 100644 index 0000000..e789afd --- /dev/null +++ b/packages/store/tests/derived.bench.ts @@ -0,0 +1,123 @@ +/* istanbul ignore file -- @preserve */ +import { bench, describe } from 'vitest' +import { shallowRef, computed as vueComputed, watchEffect } from 'vue' +import { createEffect, createMemo, createSignal } from 'solid-js' +import { + computed as preactComputed, + effect as preactEffect, + signal as preactSignal, +} from '@preact/signals' +import { + computed as angularComputed, + signal as angularSignal, +} from '@angular/core' +import { createWatch } from '@angular/core/primitives/signals' +import { Store } from '../src/store' +import { Derived } from '../src/derived' + +function noop(val: any) { + val +} + +/** + * A + * / \ + * B C + * / \ | + * D E F + * \ / | + * \ / + * G + */ +describe('Derived', () => { + bench('TanStack', () => { + const a = new Store(1) + const b = new Derived({ deps: [a], fn: () => a.state }) + b.mount() + const c = new Derived({ deps: [a], fn: () => a.state }) + c.mount() + const d = new Derived({ deps: [b], fn: () => b.state }) + d.mount() + const e = new Derived({ deps: [b], fn: () => b.state }) + e.mount() + const f = new Derived({ deps: [c], fn: () => c.state }) + f.mount() + const g = new Derived({ + deps: [d, e, f], + fn: () => d.state + e.state + f.state, + }) + g.mount() + + g.subscribe(() => noop(g.state)) + + a.setState(() => 2) + }) + + bench('Vue', () => { + const a = shallowRef(1) + const b = vueComputed(() => a.value) + const c = vueComputed(() => a.value) + const d = vueComputed(() => b.value) + const e = vueComputed(() => b.value) + const f = vueComputed(() => c.value) + const g = vueComputed(() => d.value + e.value + f.value) + + watchEffect(() => { + noop(g.value) + }) + + a.value = 2 + }) + + bench('Solid', () => { + const [a, setA] = createSignal(1) + const b = createMemo(() => a()) + const c = createMemo(() => a()) + const d = createMemo(() => b()) + const e = createMemo(() => b()) + const f = createMemo(() => c()) + const g = createMemo(() => d() + e() + f()) + + createEffect(() => { + noop(g()) + }) + + setA(2) + }) + + bench('Preact', () => { + const a = preactSignal(1) + const b = preactComputed(() => a.value) + const c = preactComputed(() => a.value) + const d = preactComputed(() => b.value) + const e = preactComputed(() => b.value) + const f = preactComputed(() => c.value) + const g = preactComputed(() => d.value + e.value + f.value) + + preactEffect(() => { + noop(g.value) + }) + + a.value = 2 + }) + + bench('Angular', () => { + const a = angularSignal(1) + const b = angularComputed(() => a()) + const c = angularComputed(() => a()) + const d = angularComputed(() => b()) + const e = angularComputed(() => b()) + const f = angularComputed(() => c()) + const g = angularComputed(() => d() + e() + f()) + + createWatch( + () => { + console.log(g()) + }, + () => {}, + false, + ) + + a.set(2) + }) +}) diff --git a/packages/store/tests/derived.test-d.ts b/packages/store/tests/derived.test-d.ts new file mode 100644 index 0000000..9fb0fc4 --- /dev/null +++ b/packages/store/tests/derived.test-d.ts @@ -0,0 +1,26 @@ +import { expectTypeOf, test } from 'vitest' +import { Derived, Store } from '../src' + +test('dep array inner types work', () => { + const store = new Store(12) + new Derived({ + deps: [store], + fn: ({ currDepVals: [currentStore], prevDepVals }) => { + expectTypeOf(currentStore).toMatchTypeOf() + expectTypeOf(prevDepVals).toMatchTypeOf<[number] | undefined>() + }, + }) +}) + +test('return type inferencing should work', () => { + const derived = new Derived({ + deps: [], + fn: ({ prevVal }) => { + // See comment in `DerivedOptions` for why this is necessary + expectTypeOf(prevVal).toMatchTypeOf() + return 12 as const + }, + }) + + expectTypeOf(derived).toMatchTypeOf>() +}) diff --git a/packages/store/tests/derived.test.ts b/packages/store/tests/derived.test.ts new file mode 100644 index 0000000..00a919b --- /dev/null +++ b/packages/store/tests/derived.test.ts @@ -0,0 +1,405 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { Store } from '../src/store' +import { Derived } from '../src/derived' +import { batch } from '../src/scheduler' + +function viFnSubscribe(subscribable: Store | Derived) { + const fn = vi.fn() + const cleanup = subscribable.subscribe(() => fn(subscribable.state)) + afterEach(() => { + cleanup() + }) + return fn +} + +describe('Derived', () => { + test('Diamond dep problem', () => { + const count = new Store(10) + + const halfCount = new Derived({ + deps: [count], + fn: () => { + return count.state / 2 + }, + }) + + halfCount.mount() + + const doubleCount = new Derived({ + deps: [count], + fn: () => { + return count.state * 2 + }, + }) + + doubleCount.mount() + + const sumDoubleHalfCount = new Derived({ + deps: [halfCount, doubleCount], + fn: () => { + return halfCount.state + doubleCount.state + }, + }) + + sumDoubleHalfCount.mount() + + const halfCountFn = viFnSubscribe(halfCount) + const doubleCountFn = viFnSubscribe(doubleCount) + const sumDoubleHalfCountFn = viFnSubscribe(sumDoubleHalfCount) + + count.setState(() => 20) + + expect(halfCountFn).toHaveBeenNthCalledWith(1, 10) + expect(doubleCountFn).toHaveBeenNthCalledWith(1, 40) + expect(sumDoubleHalfCountFn).toHaveBeenNthCalledWith(1, 50) + + count.setState(() => 30) + + expect(halfCountFn).toHaveBeenNthCalledWith(2, 15) + expect(doubleCountFn).toHaveBeenNthCalledWith(2, 60) + expect(sumDoubleHalfCountFn).toHaveBeenNthCalledWith(2, 75) + }) + + /** + * A + * / \ + * B C + * / \ | + * D E F + * \ / | + * \ / + * G + */ + test('Complex diamond dep problem', () => { + const a = new Store(1) + const b = new Derived({ deps: [a], fn: () => a.state }) + b.mount() + const c = new Derived({ deps: [a], fn: () => a.state }) + c.mount() + const d = new Derived({ deps: [b], fn: () => b.state }) + d.mount() + const e = new Derived({ deps: [b], fn: () => b.state }) + e.mount() + const f = new Derived({ deps: [c], fn: () => c.state }) + f.mount() + const g = new Derived({ + deps: [d, e, f], + fn: () => d.state + e.state + f.state, + }) + g.mount() + + const aFn = viFnSubscribe(a) + const bFn = viFnSubscribe(b) + const cFn = viFnSubscribe(c) + const dFn = viFnSubscribe(d) + const eFn = viFnSubscribe(e) + const fFn = viFnSubscribe(f) + const gFn = viFnSubscribe(g) + + a.setState(() => 2) + + expect(aFn).toHaveBeenNthCalledWith(1, 2) + expect(bFn).toHaveBeenNthCalledWith(1, 2) + expect(cFn).toHaveBeenNthCalledWith(1, 2) + expect(dFn).toHaveBeenNthCalledWith(1, 2) + expect(eFn).toHaveBeenNthCalledWith(1, 2) + expect(fFn).toHaveBeenNthCalledWith(1, 2) + expect(gFn).toHaveBeenNthCalledWith(1, 6) + }) + + test('Derive from store and another derived', () => { + const count = new Store(10) + + const doubleCount = new Derived({ + deps: [count], + fn: () => { + return count.state * 2 + }, + }) + + doubleCount.mount() + + const tripleCount = new Derived({ + deps: [count, doubleCount], + fn: () => { + return count.state + doubleCount.state + }, + }) + + tripleCount.mount() + + const doubleCountFn = viFnSubscribe(doubleCount) + const tripleCountFn = viFnSubscribe(tripleCount) + + count.setState(() => 20) + + expect(doubleCountFn).toHaveBeenNthCalledWith(1, 40) + expect(tripleCountFn).toHaveBeenNthCalledWith(1, 60) + + count.setState(() => 30) + + expect(doubleCountFn).toHaveBeenNthCalledWith(2, 60) + expect(tripleCountFn).toHaveBeenNthCalledWith(2, 90) + }) + + test('listeners should receive old and new values', () => { + const store = new Store(12) + const derived = new Derived({ + deps: [store], + fn: () => { + return store.state * 2 + }, + }) + derived.mount() + const fn = vi.fn() + derived.subscribe(fn) + store.setState(() => 24) + expect(fn).toBeCalledWith({ prevVal: 24, currentVal: 48 }) + }) + + test('derivedFn should receive old and new dep values', () => { + const count = new Store(12) + const date = new Date() + const time = new Store(date) + const fn = vi.fn() + const derived = new Derived({ + deps: [count, time], + fn: ({ prevDepVals, currDepVals }) => { + fn({ prevDepVals, currDepVals }) + return void 0 + }, + }) + derived.mount() + expect(fn).toBeCalledWith({ + prevDepVals: undefined, + currDepVals: [12, date], + }) + count.setState(() => 24) + expect(fn).toBeCalledWith({ + prevDepVals: [12, date], + currDepVals: [24, date], + }) + }) + + test('derivedFn should receive old and new dep values for similar derived values', () => { + const count = new Store(12) + const halfCount = new Derived({ + deps: [count], + fn: () => count.state / 2, + }) + halfCount.mount() + const fn = vi.fn() + const derived = new Derived({ + deps: [count, halfCount], + fn: ({ prevDepVals, currDepVals }) => { + fn({ prevDepVals, currDepVals }) + return void 0 + }, + }) + derived.mount() + expect(fn).toBeCalledWith({ + prevDepVals: undefined, + currDepVals: [12, 6], + }) + count.setState(() => 24) + expect(fn).toBeCalledWith({ + prevDepVals: [12, 6], + currDepVals: [24, 12], + }) + }) + + test('derivedFn should receive the old value', () => { + const count = new Store(12) + const date = new Date() + const time = new Store(date) + const fn = vi.fn() + const derived = new Derived({ + deps: [count, time], + fn: ({ prevVal }) => { + fn(prevVal) + return count.state + }, + }) + derived.mount() + expect(fn).toBeCalledWith(undefined) + count.setState(() => 24) + expect(fn).toBeCalledWith(12) + }) + + test('should be able to mount and unmount correctly repeatly', () => { + const count = new Store(12) + const derived = new Derived({ + deps: [count], + fn: () => { + return count.state * 2 + }, + }) + + const cleanup1 = derived.mount() + cleanup1() + const cleanup2 = derived.mount() + cleanup2() + const cleanup3 = derived.mount() + cleanup3() + derived.mount() + + count.setState(() => 24) + + expect(count.state).toBe(24) + expect(derived.state).toBe(48) + }) + + test('should handle calculating state before the derived state is mounted', () => { + const count = new Store(12) + const derived = new Derived({ + deps: [count], + fn: () => { + return count.state * 2 + }, + }) + + count.setState(() => 24) + + derived.mount() + + expect(count.state).toBe(24) + expect(derived.state).toBe(48) + }) + + test('should not recompute more than is needed', () => { + const fn = vi.fn() + const count = new Store(12) + const derived = new Derived({ + deps: [count], + fn: () => { + fn('derived') + return count.state * 2 + }, + }) + + count.setState(() => 24) + + const unmount1 = derived.mount() + unmount1() + const unmount2 = derived.mount() + unmount2() + const unmount3 = derived.mount() + unmount3() + derived.mount() + + expect(count.state).toBe(24) + expect(derived.state).toBe(48) + expect(fn).toBeCalledTimes(2) + }) + + test('should be able to mount in the wrong order and still work', () => { + const count = new Store(12) + + const double = new Derived({ + deps: [count], + fn: () => { + return count.state * 2 + }, + }) + + const halfDouble = new Derived({ + deps: [double], + fn: () => { + return double.state / 2 + }, + }) + + halfDouble.mount() + double.mount() + + count.setState(() => 24) + + expect(count.state).toBe(24) + expect(double.state).toBe(48) + expect(halfDouble.state).toBe(24) + }) + + test('should be able to mount in the wrong order and still work with a derived and a non-derived state', () => { + const count = new Store(12) + + const double = new Derived({ + deps: [count], + fn: () => { + return count.state * 2 + }, + }) + + const countPlusDouble = new Derived({ + deps: [count, double], + fn: () => { + return count.state + double.state + }, + }) + + countPlusDouble.mount() + double.mount() + + count.setState(() => 24) + + expect(count.state).toBe(24) + expect(double.state).toBe(48) + expect(countPlusDouble.state).toBe(24 + 48) + }) + + test('should recompute in the right order', () => { + const count = new Store(12) + + const fn = vi.fn() + + const double = new Derived({ + deps: [count], + fn: () => { + fn(2) + return count.state * 2 + }, + }) + + const halfDouble = new Derived({ + deps: [double, count], + fn: () => { + fn(3) + return double.state / 2 + }, + }) + + halfDouble.mount() + double.mount() + + expect(fn).toHaveBeenLastCalledWith(3) + }) + + test('should receive same prevDepVals and currDepVals during batch', () => { + const count = new Store(12) + const fn = vi.fn() + const derived = new Derived({ + deps: [count], + fn: ({ prevDepVals, currDepVals }) => { + fn({ prevDepVals, currDepVals }) + return count.state + }, + }) + derived.mount() + + // First call when mounting + expect(fn).toHaveBeenNthCalledWith(1, { + prevDepVals: undefined, + currDepVals: [12], + }) + + batch(() => { + count.setState(() => 23) + count.setState(() => 24) + count.setState(() => 25) + }) + + expect(fn).toHaveBeenNthCalledWith(2, { + prevDepVals: [12], + currDepVals: [25], + }) + }) +}) diff --git a/packages/store/tests/effect.test.ts b/packages/store/tests/effect.test.ts new file mode 100644 index 0000000..9127efc --- /dev/null +++ b/packages/store/tests/effect.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test, vi } from 'vitest' +import { Store } from '../src/store' +import { Derived } from '../src/derived' +import { Effect } from '../src/effect' + +describe('Effect', () => { + test('Side effect free', () => { + const count = new Store(10) + + const halfCount = new Derived({ + deps: [count], + fn: () => { + return count.state / 2 + }, + }) + + halfCount.mount() + + const doubleCount = new Derived({ + deps: [count], + fn: () => { + return count.state * 2 + }, + }) + + doubleCount.mount() + + const sumDoubleHalfCount = new Derived({ + deps: [halfCount, doubleCount], + fn: () => { + return halfCount.state + doubleCount.state + }, + }) + + sumDoubleHalfCount.mount() + + const fn = vi.fn() + const effect = new Effect({ + deps: [sumDoubleHalfCount], + fn: () => fn(sumDoubleHalfCount.state), + }) + effect.mount() + + count.setState(() => 20) + + expect(fn).toHaveBeenNthCalledWith(1, 50) + + count.setState(() => 30) + + expect(fn).toHaveBeenNthCalledWith(2, 75) + }) + + /** + * A + * / \ + * B C + * / \ | + * D E F + * \ / | + * \ / + * G + */ + test('Complex diamond dep problem', () => { + const a = new Store(1) + const b = new Derived({ deps: [a], fn: () => a.state }) + b.mount() + const c = new Derived({ deps: [a], fn: () => a.state }) + c.mount() + const d = new Derived({ deps: [b], fn: () => b.state }) + d.mount() + const e = new Derived({ deps: [b], fn: () => b.state }) + e.mount() + const f = new Derived({ deps: [c], fn: () => c.state }) + f.mount() + const g = new Derived({ + deps: [d, e, f], + fn: () => d.state + e.state + f.state, + }) + g.mount() + + const fn = vi.fn() + const effect = new Effect({ deps: [g], fn: () => fn(g.state) }) + effect.mount() + + a.setState(() => 2) + + expect(fn).toHaveBeenNthCalledWith(1, 6) + }) +}) diff --git a/packages/store/tests/scheduler.test.ts b/packages/store/tests/scheduler.test.ts new file mode 100644 index 0000000..c09f564 --- /dev/null +++ b/packages/store/tests/scheduler.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test, vi } from 'vitest' +import { + Derived, + Store, + __derivedToStore, + __storeToDerived, + batch, +} from '../src' + +describe('Scheduler logic', () => { + test('Should build a graph properly', () => { + const count = new Store(10) + + const halfCount = new Derived({ + deps: [count], + fn: () => { + return count.state / 2 + }, + }) + + halfCount.registerOnGraph() + + const doubleHalfCount = new Derived({ + deps: [halfCount], + fn: () => { + return halfCount.state * 2 + }, + }) + + doubleHalfCount.registerOnGraph() + + expect(__storeToDerived.get(count)).toContain(halfCount) + expect(__derivedToStore.get(halfCount)).toContain(count) + expect(__storeToDerived.get(count)).toContain(doubleHalfCount) + expect(__derivedToStore.get(doubleHalfCount)).toContain(count) + }) + + test('should unbuild a graph properly', () => { + const count = new Store(10) + + const halfCount = new Derived({ + deps: [count], + fn: () => { + return count.state / 2 + }, + }) + + halfCount.registerOnGraph() + + const doubleHalfCount = new Derived({ + deps: [halfCount], + fn: () => { + return halfCount.state * 2 + }, + }) + + doubleHalfCount.registerOnGraph() + + doubleHalfCount.unregisterFromGraph() + + expect(__storeToDerived.get(count)).toContain(halfCount) + expect(__derivedToStore.get(halfCount)).toContain(count) + expect(__storeToDerived.get(count)).not.toContain(doubleHalfCount) + expect(__derivedToStore.get(doubleHalfCount)).not.toContain(count) + + halfCount.unregisterFromGraph() + + expect(__storeToDerived.get(count)).not.toContain(halfCount) + expect(__derivedToStore.get(halfCount)).not.toContain(count) + }) + + test('Batch prevents listeners from being called during repeated setStates', () => { + const store = new Store(0) + + const listener = vi.fn() + + const unsub = store.subscribe(listener) + + batch(() => { + store.setState(() => 1) + store.setState(() => 2) + store.setState(() => 3) + store.setState(() => 4) + }) + + expect(store.state).toEqual(4) + // Listener is only called once because of batching + expect(listener).toHaveBeenCalledTimes(1) + + store.setState(() => 1) + store.setState(() => 2) + store.setState(() => 3) + store.setState(() => 4) + + expect(store.state).toEqual(4) + // Listener is called 4 times because of a lack of batching + expect(listener).toHaveBeenCalledTimes(5) + unsub() + }) + + test('should register graph items in the wrong order properly', () => { + const count = new Store(12) + + const double = new Derived({ + deps: [count], + fn: () => { + return count.state * 2 + }, + }) + + const halfDouble = new Derived({ + deps: [double], + fn: () => { + return double.state / 2 + }, + }) + + halfDouble.registerOnGraph() + + expect(__storeToDerived.get(count)).toContain(halfDouble) + expect(__derivedToStore.get(halfDouble)).toContain(count) + expect(__storeToDerived.get(count)).toContain(double) + expect(__derivedToStore.get(double)).toContain(count) + }) + + test('should register graph items in the right direction order', () => { + const count = new Store(12) + + const double = new Derived({ + deps: [count], + fn: () => { + return count.state * 2 + }, + }) + + const halfDouble = new Derived({ + deps: [double], + fn: () => { + return double.state / 2 + }, + }) + + halfDouble.registerOnGraph() + + expect(Array.from(__storeToDerived.get(count)!)).toEqual([ + double, + halfDouble, + ]) + }) +}) diff --git a/packages/store/tests/index.test.tsx b/packages/store/tests/store.test.ts similarity index 61% rename from packages/store/tests/index.test.tsx rename to packages/store/tests/store.test.ts index 6c2e64e..5d7b9ae 100644 --- a/packages/store/tests/index.test.tsx +++ b/packages/store/tests/store.test.ts @@ -53,31 +53,11 @@ describe('store', () => { expect(typeof store.state).toEqual('number') }) - test('Batch prevents listeners from being called during repeated setStates', () => { - const store = new Store(0) - - const listener = vi.fn() - - store.subscribe(listener) - - store.batch(() => { - store.setState(() => 1) - store.setState(() => 2) - store.setState(() => 3) - store.setState(() => 4) - }) - - expect(store.state).toEqual(4) - // Listener is only called once because of batching - expect(listener).toHaveBeenCalledTimes(1) - - store.setState(() => 1) - store.setState(() => 2) - store.setState(() => 3) - store.setState(() => 4) - - expect(store.state).toEqual(4) - // Listener is called 4 times because of a lack of batching - expect(listener).toHaveBeenCalledTimes(5) + test('listeners should receive old and new values', () => { + const store = new Store(12) + const fn = vi.fn() + store.subscribe(fn) + store.setState(() => 24) + expect(fn).toBeCalledWith({ prevVal: 12, currentVal: 24 }) }) }) diff --git a/packages/store/tsconfig.legacy.json b/packages/store/tsconfig.legacy.json deleted file mode 100644 index 596e2cf..0000000 --- a/packages/store/tsconfig.legacy.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src"] -} diff --git a/packages/svelte-store/src/index.svelte.ts b/packages/svelte-store/src/index.svelte.ts index f13f545..3d8429a 100644 --- a/packages/svelte-store/src/index.svelte.ts +++ b/packages/svelte-store/src/index.svelte.ts @@ -1,4 +1,4 @@ -import type { AnyUpdater, Store } from '@tanstack/store' +import type { Derived, Store } from '@tanstack/store' export * from '@tanstack/store' @@ -7,14 +7,18 @@ export * from '@tanstack/store' */ export type NoInfer = [T][T extends any ? 0 : never] -export function useStore< - TState, - TSelected = NoInfer, - TUpdater extends AnyUpdater = AnyUpdater, ->( - store: Store, +export function useStore>( + store: Store, + selector?: (state: NoInfer) => TSelected, +): { readonly current: TSelected } +export function useStore>( + store: Derived, + selector?: (state: NoInfer) => TSelected, +): { readonly current: TSelected } +export function useStore>( + store: Store | Derived, selector: (state: NoInfer) => TSelected = (d) => d as any, -) { +): { readonly current: TSelected } { let slice = $state(selector(store.state)) $effect(() => { diff --git a/packages/vue-store/package.json b/packages/vue-store/package.json index 0356cda..6192d29 100644 --- a/packages/vue-store/package.json +++ b/packages/vue-store/package.json @@ -23,7 +23,6 @@ "clean": "rimraf ./dist && rimraf ./coverage", "test:eslint": "eslint ./src ./tests", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", - "test:types:ts49": "node ../../node_modules/typescript49/lib/tsc.js -p tsconfig.legacy.json", "test:types:ts50": "node ../../node_modules/typescript50/lib/tsc.js", "test:types:ts51": "node ../../node_modules/typescript51/lib/tsc.js", "test:types:ts52": "node ../../node_modules/typescript52/lib/tsc.js", diff --git a/packages/vue-store/src/index.ts b/packages/vue-store/src/index.ts index 8db080a..0d3dfaa 100644 --- a/packages/vue-store/src/index.ts +++ b/packages/vue-store/src/index.ts @@ -1,5 +1,5 @@ import { readonly, ref, toRaw, watch } from 'vue-demi' -import type { AnyUpdater, Store } from '@tanstack/store' +import type { Derived, Store } from '@tanstack/store' import type { Ref } from 'vue-demi' export * from '@tanstack/store' @@ -9,12 +9,16 @@ export * from '@tanstack/store' */ export type NoInfer = [T][T extends any ? 0 : never] -export function useStore< - TState, - TSelected = NoInfer, - TUpdater extends AnyUpdater = AnyUpdater, ->( - store: Store, +export function useStore>( + store: Store, + selector?: (state: NoInfer) => TSelected, +): Readonly> +export function useStore>( + store: Derived, + selector?: (state: NoInfer) => TSelected, +): Readonly> +export function useStore>( + store: Store | Derived, selector: (state: NoInfer) => TSelected = (d) => d as any, ): Readonly> { const slice = ref(selector(store.state)) as Ref diff --git a/packages/vue-store/tests/test.test-d.ts b/packages/vue-store/tests/test.test-d.ts new file mode 100644 index 0000000..6c53f9c --- /dev/null +++ b/packages/vue-store/tests/test.test-d.ts @@ -0,0 +1,20 @@ +import { expectTypeOf, test } from 'vitest' +import { Derived, Store, useStore } from '../src' +import type { Ref } from 'vue-demi' + +test('useStore works with derived state', () => { + const store = new Store(12) + const derived = new Derived({ + deps: [store], + fn: () => { + return { val: store.state * 2 } + }, + }) + + const val = useStore(derived, (state) => { + expectTypeOf(state).toMatchTypeOf<{ val: number }>() + return state.val + }) + + expectTypeOf(val).toMatchTypeOf>>() +}) diff --git a/packages/vue-store/tsconfig.legacy.json b/packages/vue-store/tsconfig.legacy.json deleted file mode 100644 index 05c9a49..0000000 --- a/packages/vue-store/tsconfig.legacy.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "jsx": "preserve", - "jsxImportSource": "vue", - "types": ["vue/jsx"], - "paths": { - "@tanstack/store": ["../store/src"] - } - }, - "include": ["src"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d40d43..9ff3f6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,9 +83,6 @@ importers: typescript: specifier: 5.6.3 version: 5.6.3 - typescript49: - specifier: npm:typescript@4.9 - version: typescript@4.9.5 typescript50: specifier: npm:typescript@5.0 version: typescript@5.0.4 @@ -338,7 +335,20 @@ importers: specifier: ^2.11.0 version: 2.11.0(@testing-library/jest-dom@6.6.3)(solid-js@1.9.3)(vite@5.4.11(@types/node@22.10.1)(less@4.2.0)(sass@1.80.7)(terser@5.36.0)) - packages/store: {} + packages/store: + devDependencies: + '@angular/core': + specifier: ^19.0.5 + version: 19.0.5(rxjs@7.8.1)(zone.js@0.15.0) + '@preact/signals': + specifier: ^1.3.0 + version: 1.3.0(preact@10.25.0) + solid-js: + specifier: ^1.9.3 + version: 1.9.3 + vue: + specifier: ^3.5.13 + version: 3.5.13(typescript@5.6.3) packages/svelte-store: dependencies: @@ -2033,6 +2043,14 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@preact/signals-core@1.8.0': + resolution: {integrity: sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==} + + '@preact/signals@1.3.0': + resolution: {integrity: sha512-EOMeg42SlLS72dhoq6Vjq08havnLseWmPQ8A0YsgIAqMgWgx7V1a39+Pxo6i7SY5NwJtH4849JogFq3M67AzWg==} + peerDependencies: + preact: 10.x + '@rollup/plugin-json@6.1.0': resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} engines: {node: '>=14.0.0'} @@ -5448,6 +5466,9 @@ packages: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} + preact@10.25.0: + resolution: {integrity: sha512-6bYnzlLxXV3OSpUxLdaxBmE7PMOu0aR3pG6lryK/0jmvcDFPlcXGQAt5DpK3RITWiDrfYZRI0druyaK/S9kYLg==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -6335,11 +6356,6 @@ packages: typescript: optional: true - typescript@4.9.5: - resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} - engines: {node: '>=4.2.0'} - hasBin: true - typescript@5.0.4: resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==} engines: {node: '>=12.20'} @@ -8671,6 +8687,13 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@preact/signals-core@1.8.0': {} + + '@preact/signals@1.3.0(preact@10.25.0)': + dependencies: + '@preact/signals-core': 1.8.0 + preact: 10.25.0 + '@rollup/plugin-json@6.1.0(rollup@4.26.0)': dependencies: '@rollup/pluginutils': 5.1.3(rollup@4.26.0) @@ -12601,6 +12624,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.25.0: {} + prelude-ls@1.2.1: {} prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.15.0): @@ -13508,8 +13533,6 @@ snapshots: transitivePeerDependencies: - supports-color - typescript@4.9.5: {} - typescript@5.0.4: {} typescript@5.1.6: {}