From 49cf95924cc58f1631bd4d6f34fb381031e07e5a Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 16 Jan 2025 01:04:57 -0500 Subject: [PATCH 01/25] Only complete assigners are allowed --- .changeset/thick-paws-invite.md | 5 ++ packages/xstate-store/src/store.ts | 90 +++++++------------- packages/xstate-store/src/types.ts | 2 +- packages/xstate-store/test/fromStore.test.ts | 9 +- packages/xstate-store/test/react.test.tsx | 49 ++++++----- packages/xstate-store/test/solid.test.tsx | 20 ++++- packages/xstate-store/test/store.test.ts | 33 +++---- packages/xstate-store/test/types.test.tsx | 50 +++++------ 8 files changed, 124 insertions(+), 134 deletions(-) create mode 100644 .changeset/thick-paws-invite.md diff --git a/.changeset/thick-paws-invite.md b/.changeset/thick-paws-invite.md new file mode 100644 index 0000000000..73f1274018 --- /dev/null +++ b/.changeset/thick-paws-invite.md @@ -0,0 +1,5 @@ +--- +'@xstate/store': major +--- + +Only complete assigners can now be used diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 6a651d58be..89c13ab3d0 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -9,11 +9,8 @@ import { Recipe, Store, StoreAssigner, - StoreCompleteAssigner, StoreContext, StoreInspectionEvent, - StorePartialAssigner, - StorePropertyAssigner, StoreSnapshot } from './types'; @@ -57,17 +54,11 @@ function createStoreCore< >( initialContext: TContext, transitions: { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - TEmitted - > - | StorePropertyAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - TEmitted - >; + [K in keyof TEventPayloadMap & string]: StoreAssigner< + NoInfer, + { type: K } & TEventPayloadMap[K], + TEmitted + >; }, updater?: ( context: NoInfer, @@ -207,21 +198,13 @@ export type TransitionsFromEventPayloadMap< TContext extends StoreContext, TEmitted extends EventObject > = { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner< - TContext, - { - type: K; - } & TEventPayloadMap[K], - TEmitted - > - | StorePropertyAssigner< - TContext, - { - type: K; - } & TEventPayloadMap[K], - TEmitted - >; + [K in keyof TEventPayloadMap & string]: StoreAssigner< + TContext, + { + type: K; + } & TEventPayloadMap[K], + TEmitted + >; }; /** @@ -264,17 +247,11 @@ export function createStore< }: { context: TContext; on: { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - > - | StorePropertyAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - >; + [K in keyof TEventPayloadMap & string]: StoreAssigner< + NoInfer, + { type: K } & TEventPayloadMap[K], + Cast + >; }; } & { types?: TTypes }): Store< TContext, @@ -445,13 +422,11 @@ export function createStoreTransition< TEmitted extends EventObject >( transitions: { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner - | StorePropertyAssigner< - TContext, - { type: K } & TEventPayloadMap[K], - TEmitted - >; + [K in keyof TEventPayloadMap & string]: StoreAssigner< + TContext, + { type: K } & TEventPayloadMap[K], + TEmitted + >; }, updater?: ( context: TContext, @@ -480,9 +455,11 @@ export function createStoreTransition< if (typeof assigner === 'function') { currentContext = updater ? updater(currentContext, (draftContext) => - ( - assigner as StoreCompleteAssigner - )?.(draftContext, event, enqueue) + (assigner as StoreAssigner)?.( + draftContext, + event, + enqueue + ) ) : setter(currentContext, (draftContext) => Object.assign( @@ -501,14 +478,11 @@ export function createStoreTransition< const propAssignment = assigner[key]; partialUpdate[key] = typeof propAssignment === 'function' - ? ( - propAssignment as StorePartialAssigner< - TContext, - StoreEvent, - typeof key, - TEmitted - > - )(currentContext, event, enqueue) + ? (propAssignment as StoreAssigner)( + currentContext, + event, + enqueue + ) : propAssignment; } currentContext = Object.assign({}, currentContext, partialUpdate); diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index 23b90758b8..3dae424ae7 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -18,7 +18,7 @@ export type StoreAssigner< context: TContext, event: TEvent, enq: EnqueueObject -) => Partial; +) => TContext; export type StoreCompleteAssigner< TContext, TEvent extends EventObject, diff --git a/packages/xstate-store/test/fromStore.test.ts b/packages/xstate-store/test/fromStore.test.ts index 0f6cc1d75d..d1d7e2349e 100644 --- a/packages/xstate-store/test/fromStore.test.ts +++ b/packages/xstate-store/test/fromStore.test.ts @@ -4,11 +4,10 @@ import { fromStore } from '../src/index.ts'; describe('fromStore', () => { it('creates an actor from store logic with input (2 args)', () => { const storeLogic = fromStore((count: number) => ({ count }), { - inc: { - count: (ctx, ev: { by: number }) => { - return ctx.count + ev.by; - } - } + inc: (ctx, ev: { by: number }) => ({ + ...ctx, + count: ctx.count + ev.by + }) }); const actor = createActor(storeLogic, { diff --git a/packages/xstate-store/test/react.test.tsx b/packages/xstate-store/test/react.test.tsx index b05dcba39f..889c025ef5 100644 --- a/packages/xstate-store/test/react.test.tsx +++ b/packages/xstate-store/test/react.test.tsx @@ -14,9 +14,10 @@ it('useSelector should work', () => { count: 0 }, { - inc: { - count: (ctx) => ctx.count + 1 - } + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } ); @@ -52,12 +53,14 @@ it('useSelector can take in a custom comparator', () => { items: [1, 2] }, { - same: { - items: () => [1, 2] // different array, same items - }, - different: { - items: () => [3, 4] - } + same: (ctx) => ({ + ...ctx, + items: [1, 2] // different array, same items + }), + different: (ctx) => ({ + ...ctx, + items: [3, 4] + }) } ); @@ -117,9 +120,10 @@ it('can batch updates', () => { count: 0 }, { - inc: { - count: (ctx) => ctx.count + 1 - } + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } ); @@ -158,9 +162,10 @@ it('useSelector (@xstate/react) should work', () => { count: 0 }, { - inc: { - count: (ctx) => ctx.count + 1 - } + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } ); @@ -196,9 +201,10 @@ it('useActor (@xstate/react) should work', () => { count: 0 }, { - inc: { - count: (ctx) => ctx.count + 1 - } + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } ); @@ -234,9 +240,10 @@ it('useActorRef (@xstate/react) should work', () => { count: 0 }, { - inc: { - count: (ctx) => ctx.count + 1 - } + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } ); diff --git a/packages/xstate-store/test/solid.test.tsx b/packages/xstate-store/test/solid.test.tsx index f690da0a91..fd94bd5a00 100644 --- a/packages/xstate-store/test/solid.test.tsx +++ b/packages/xstate-store/test/solid.test.tsx @@ -22,8 +22,14 @@ const createCounterStore = () => createStore( { count: 0, other: 0 }, { - increment: { count: ({ count }) => count + 1 }, - other: { other: ({ other }) => other + 1 } + increment: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }), + other: (ctx) => ({ + ...ctx, + other: ctx.other + 1 + }) } ); @@ -75,8 +81,14 @@ describe('Solid.js integration', () => { const store = createStore( { items: INITIAL_ITEMS }, { - same: { items: () => [...INITIAL_ITEMS] }, - different: { items: () => DIFFERENT_ITEMS } + same: (ctx) => ({ + ...ctx, + items: [...INITIAL_ITEMS] + }), + different: (ctx) => ({ + ...ctx, + items: DIFFERENT_ITEMS + }) } ); diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index 2e5db1bd07..aa270547aa 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -27,13 +27,14 @@ it('can update context with a property assigner', () => { const store = createStore( { count: 0, greeting: 'hello' }, { - inc: { - count: (ctx) => ctx.count + 1 - }, - updateBoth: { - count: () => 42, + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }), + updateBoth: (ctx) => ({ + count: 42, greeting: 'hi' - } + }) } ); @@ -52,9 +53,9 @@ it('handles unknown events (does not do anything)', () => { const store = createStore( { count: 0 }, { - inc: { - count: (ctx) => ctx.count + 1 - } + inc: (ctx) => ({ + count: ctx.count + 1 + }) } ); @@ -165,7 +166,7 @@ it('createStoreWithProducer(…) infers the context type properly with a produce count: 0 }, on: { - inc: (ctx, ev: { by: number }, enq) => { + inc: (ctx, ev: { by: number }) => { ctx.count += ev.by; } } @@ -180,9 +181,9 @@ it('can be observed', () => { count: 0 }, { - inc: { - count: (ctx) => ctx.count + 1 - } + inc: (ctx) => ({ + count: ctx.count + 1 + }) } ); @@ -211,9 +212,9 @@ it('can be inspected', () => { count: 0 }, { - inc: { - count: (ctx) => ctx.count + 1 - } + inc: (ctx) => ({ + count: ctx.count + 1 + }) } ); diff --git a/packages/xstate-store/test/types.test.tsx b/packages/xstate-store/test/types.test.tsx index 2bbf40e24a..6cd59d3d35 100644 --- a/packages/xstate-store/test/types.test.tsx +++ b/packages/xstate-store/test/types.test.tsx @@ -10,11 +10,9 @@ describe('emitted', () => { }, context: {}, on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); - return ctx; - } + inc: (ctx, _, enq) => { + enq.emit({ type: 'increased', upBy: 1 }); + return ctx; } } }); @@ -29,14 +27,12 @@ describe('emitted', () => { }, context: {}, on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ - // @ts-expect-error - type: 'unknown' - }); - return ctx; - } + inc: (ctx, _, enq) => { + enq.emit({ + // @ts-expect-error + type: 'unknown' + }); + return ctx; } } }); @@ -51,15 +47,13 @@ describe('emitted', () => { }, context: {}, on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ - type: 'increased', - // @ts-expect-error - upBy: 'bazinga' - }); - return ctx; - } + inc: (ctx, _, enq) => { + enq.emit({ + type: 'increased', + // @ts-expect-error + upBy: 'bazinga' + }); + return ctx; } } }); @@ -69,13 +63,11 @@ describe('emitted', () => { createStore({ context: {}, on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ - type: 'unknown' - }); - return ctx; - } + inc: (ctx, _, enq) => { + enq.emit({ + type: 'unknown' + }); + return ctx; } } }); From a0b5dd2f92ffd45d32572dae943cc850f80b7297 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 18 Jan 2025 19:28:43 -0500 Subject: [PATCH 02/25] `createStore` only takes a single { context, on } argument now --- .changeset/five-walls-approve.md | 5 ++ packages/xstate-store/src/store.ts | 55 ++----------------- packages/xstate-store/test/UseSelector.vue | 8 +-- packages/xstate-store/test/react.test.tsx | 32 +++++------ packages/xstate-store/test/solid.test.tsx | 16 +++--- packages/xstate-store/test/store.test.ts | 62 +++++++++++----------- 6 files changed, 68 insertions(+), 110 deletions(-) create mode 100644 .changeset/five-walls-approve.md diff --git a/.changeset/five-walls-approve.md b/.changeset/five-walls-approve.md new file mode 100644 index 0000000000..83410651b0 --- /dev/null +++ b/.changeset/five-walls-approve.md @@ -0,0 +1,5 @@ +--- +'@xstate/store': major +--- + +`createStore` only takes a single { context, on } argument now diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 89c13ab3d0..5c1c121009 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -242,8 +242,7 @@ export function createStore< TTypes extends { emitted?: EventObject } >({ context, - on, - types + on }: { context: TContext; on: { @@ -257,56 +256,8 @@ export function createStore< TContext, ExtractEventsFromPayloadMap, Cast ->; - -/** - * Creates a **store** that has its own internal state and can be sent events - * that update its internal state based on transitions. - * - * @example - * - * ```ts - * const store = createStore( - * // Initial context - * { count: 0 }, - * // Transitions - * { - * inc: (context, event: { by: number }) => { - * return { - * count: context.count + event.by - * }; - * } - * } - * ); - * - * store.subscribe((snapshot) => { - * console.log(snapshot); - * }); - * - * store.send({ type: 'inc', by: 5 }); - * // Logs { context: { count: 5 }, status: 'active', ... } - * ``` - */ -export function createStore< - TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap ->( - initialContext: TContext, - transitions: TransitionsFromEventPayloadMap< - TEventPayloadMap, - TContext, - EventObject - > -): Store, EventObject>; - -export function createStore(initialContextOrObject: any, transitions?: any) { - if (transitions === undefined) { - return createStoreCore( - initialContextOrObject.context, - initialContextOrObject.on - ); - } - return createStoreCore(initialContextOrObject, transitions); +> { + return createStoreCore(context, on); } /** diff --git a/packages/xstate-store/test/UseSelector.vue b/packages/xstate-store/test/UseSelector.vue index 9f4c09019f..d38af28c50 100644 --- a/packages/xstate-store/test/UseSelector.vue +++ b/packages/xstate-store/test/UseSelector.vue @@ -14,14 +14,14 @@ import { createStore } from '../src/index.ts'; export default defineComponent({ emits: ['rerender'], setup() { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ count: ctx.count + 1 }) } - ); + }); const count = useSelector(store, (state) => state.context.count); count satisfies Ref; diff --git a/packages/xstate-store/test/react.test.tsx b/packages/xstate-store/test/react.test.tsx index 889c025ef5..d626412803 100644 --- a/packages/xstate-store/test/react.test.tsx +++ b/packages/xstate-store/test/react.test.tsx @@ -9,17 +9,17 @@ import { import ReactDOM from 'react-dom'; it('useSelector should work', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ ...ctx, count: ctx.count + 1 }) } - ); + }); const Counter = () => { const count = useSelector(store, (s) => s.context.count); @@ -48,11 +48,11 @@ it('useSelector should work', () => { }); it('useSelector can take in a custom comparator', () => { - const store = createStore( - { + const store = createStore({ + context: { items: [1, 2] }, - { + on: { same: (ctx) => ({ ...ctx, items: [1, 2] // different array, same items @@ -62,7 +62,7 @@ it('useSelector can take in a custom comparator', () => { items: [3, 4] }) } - ); + }); let renderCount = 0; const Items = () => { @@ -115,17 +115,17 @@ it('useSelector can take in a custom comparator', () => { }); it('can batch updates', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ ...ctx, count: ctx.count + 1 }) } - ); + }); const Counter = () => { const count = useSelector(store, (s) => s.context.count); @@ -157,17 +157,17 @@ it('can batch updates', () => { }); it('useSelector (@xstate/react) should work', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ ...ctx, count: ctx.count + 1 }) } - ); + }); const Counter = () => { const count = useXStateSelector(store, (s) => s.context.count); diff --git a/packages/xstate-store/test/solid.test.tsx b/packages/xstate-store/test/solid.test.tsx index fd94bd5a00..1d8698cfae 100644 --- a/packages/xstate-store/test/solid.test.tsx +++ b/packages/xstate-store/test/solid.test.tsx @@ -19,9 +19,9 @@ const useRenderTracker = (...accessors: Accessor[]) => { /** A commonly reused store for testing selector behaviours. */ const createCounterStore = () => - createStore( - { count: 0, other: 0 }, - { + createStore({ + context: { count: 0, other: 0 }, + on: { increment: (ctx) => ({ ...ctx, count: ctx.count + 1 @@ -31,7 +31,7 @@ const createCounterStore = () => other: ctx.other + 1 }) } - ); + }); describe('Solid.js integration', () => { describe('useSelector', () => { @@ -78,9 +78,9 @@ describe('Solid.js integration', () => { const INITIAL_ITEMS_STRING = INITIAL_ITEMS.join(','); const DIFFERENT_ITEMS_STRING = DIFFERENT_ITEMS.join(','); - const store = createStore( - { items: INITIAL_ITEMS }, - { + const store = createStore({ + context: { items: INITIAL_ITEMS }, + on: { same: (ctx) => ({ ...ctx, items: [...INITIAL_ITEMS] @@ -90,7 +90,7 @@ describe('Solid.js integration', () => { items: DIFFERENT_ITEMS }) } - ); + }); const ItemList: Component<{ itemStore: typeof store; diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index aa270547aa..2b286be72d 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -4,11 +4,14 @@ import { createBrowserInspector } from '@statelyai/inspect'; it('updates a store with an event without mutating original context', () => { const context = { count: 0 }; - const store = createStore(context, { - inc: (context, event: { by: number }) => { - return { - count: context.count + event.by - }; + const store = createStore({ + context, + on: { + inc: (context, event: { by: number }) => { + return { + count: context.count + event.by + }; + } } }); @@ -24,9 +27,9 @@ it('updates a store with an event without mutating original context', () => { }); it('can update context with a property assigner', () => { - const store = createStore( - { count: 0, greeting: 'hello' }, - { + const store = createStore({ + context: { count: 0, greeting: 'hello' }, + on: { inc: (ctx) => ({ ...ctx, count: ctx.count + 1 @@ -36,7 +39,7 @@ it('can update context with a property assigner', () => { greeting: 'hi' }) } - ); + }); store.send({ type: 'inc' @@ -50,14 +53,14 @@ it('can update context with a property assigner', () => { }); it('handles unknown events (does not do anything)', () => { - const store = createStore( - { count: 0 }, - { + const store = createStore({ + context: { count: 0 }, + on: { inc: (ctx) => ({ count: ctx.count + 1 }) } - ); + }); store.send({ // @ts-expect-error @@ -67,11 +70,11 @@ it('handles unknown events (does not do anything)', () => { }); it('updates state from sent events', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { + on: { inc: (ctx, ev: { by: number }) => { return { count: ctx.count + ev.by @@ -88,7 +91,7 @@ it('updates state from sent events', () => { }; } } - ); + }); store.send({ type: 'inc', by: 9 }); store.send({ type: 'dec', by: 3 }); @@ -100,17 +103,16 @@ it('updates state from sent events', () => { }); it('createStoreWithProducer(…) works with an immer producer', () => { - const store = createStoreWithProducer( - produce, - { + const store = createStoreWithProducer(produce, { + context: { count: 0 }, - { + on: { inc: (ctx, ev: { by: number }) => { ctx.count += ev.by; } } - ); + }); store.send({ type: 'inc', by: 3 }); store.send({ @@ -176,16 +178,16 @@ it('createStoreWithProducer(…) infers the context type properly with a produce }); it('can be observed', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ count: ctx.count + 1 }) } - ); + }); const counts: number[] = []; @@ -207,16 +209,16 @@ it('can be observed', () => { }); it('can be inspected', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ count: ctx.count + 1 }) } - ); + }); const evs: any[] = []; From 4f3d224ae36b1ad696fc42b349d8afe51916f1f3 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 19 Jan 2025 12:49:52 -0500 Subject: [PATCH 03/25] createStoreWithProducer --- .changeset/spotty-moose-joke.md | 5 ++ packages/xstate-store/src/fromStore.ts | 35 ++++------- packages/xstate-store/src/store.ts | 62 ++++---------------- packages/xstate-store/src/types.ts | 28 ++------- packages/xstate-store/test/fromStore.test.ts | 25 +++++--- packages/xstate-store/test/store.test.ts | 9 ++- 6 files changed, 52 insertions(+), 112 deletions(-) create mode 100644 .changeset/spotty-moose-joke.md diff --git a/.changeset/spotty-moose-joke.md b/.changeset/spotty-moose-joke.md new file mode 100644 index 0000000000..1a706ce688 --- /dev/null +++ b/.changeset/spotty-moose-joke.md @@ -0,0 +1,5 @@ +--- +'@xstate/store': major +--- + +createStoreWithProducer now only accepts a single config object diff --git a/packages/xstate-store/src/fromStore.ts b/packages/xstate-store/src/fromStore.ts index 5469e4b9ea..70908aec08 100644 --- a/packages/xstate-store/src/fromStore.ts +++ b/packages/xstate-store/src/fromStore.ts @@ -7,8 +7,7 @@ import { StoreSnapshot, EventObject, ExtractEventsFromPayloadMap, - StoreAssigner, - StorePropertyAssigner + StoreAssigner } from './types'; type StoreLogic< @@ -66,17 +65,11 @@ export function fromStore< config: { context: ((input: TInput) => TContext) | TContext; on: { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - > - | StorePropertyAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - >; + [K in keyof TEventPayloadMap & string]: StoreAssigner< + NoInfer, + { type: K } & TEventPayloadMap[K], + Cast + >; }; } & { types?: TTypes } ): StoreLogic< @@ -97,17 +90,11 @@ export function fromStore< | ({ context: ((input: TInput) => TContext) | TContext; on: { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - > - | StorePropertyAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - >; + [K in keyof TEventPayloadMap & string]: StoreAssigner< + NoInfer, + { type: K } & TEventPayloadMap[K], + Cast + >; }; } & { types?: TTypes }), transitions?: TransitionsFromEventPayloadMap< diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 5c1c121009..33bd54bc8e 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -11,6 +11,7 @@ import { StoreAssigner, StoreContext, StoreInspectionEvent, + StoreProducerAssigner, StoreSnapshot } from './types'; @@ -60,9 +61,9 @@ function createStoreCore< TEmitted >; }, - updater?: ( + producer?: ( context: NoInfer, - recipe: (context: NoInfer) => NoInfer + recipe: (context: NoInfer) => void ) => NoInfer ): Store, TEmitted> { type StoreEvent = ExtractEventsFromPayloadMap; @@ -87,7 +88,7 @@ function createStoreCore< } }; - const transition = createStoreTransition(transitions, updater); + const transition = createStoreTransition(transitions, producer); function receive(event: StoreEvent) { let emitted: TEmitted[]; @@ -308,49 +309,8 @@ export function createStoreWithProducer< ) => void; }; } -): Store, TEmitted>; -export function createStoreWithProducer< - TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventObject = EventObject ->( - producer: NoInfer< - (context: TContext, recipe: (context: TContext) => void) => TContext - >, - initialContext: TContext, - transitions: { - [K in keyof TEventPayloadMap & string]: ( - context: NoInfer, - event: { type: K } & TEventPayloadMap[K], - enqueue: EnqueueObject - ) => void; - } -): Store, TEmitted>; - -export function createStoreWithProducer< - TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventObject = EventObject ->( - producer: ( - context: TContext, - recipe: (context: TContext) => void - ) => TContext, - initialContextOrConfig: any, - transitions?: any ): Store, TEmitted> { - if ( - typeof initialContextOrConfig === 'object' && - 'context' in initialContextOrConfig && - 'on' in initialContextOrConfig - ) { - return createStoreCore( - initialContextOrConfig.context, - initialContextOrConfig.on, - producer - ); - } - return createStoreCore(initialContextOrConfig, transitions, producer); + return createStoreCore(config.context, config.on, producer); } declare global { @@ -364,7 +324,7 @@ declare global { * snapshot and an event and returns a new snapshot. * * @param transitions - * @param updater + * @param producer * @returns */ export function createStoreTransition< @@ -379,9 +339,9 @@ export function createStoreTransition< TEmitted >; }, - updater?: ( + producer?: ( context: TContext, - recipe: (context: TContext) => TContext + recipe: (context: TContext) => void ) => TContext ) { return ( @@ -404,9 +364,9 @@ export function createStoreTransition< } if (typeof assigner === 'function') { - currentContext = updater - ? updater(currentContext, (draftContext) => - (assigner as StoreAssigner)?.( + currentContext = producer + ? producer(currentContext, (draftContext) => + (assigner as StoreProducerAssigner)( draftContext, event, enqueue diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index 3dae424ae7..99a0a764cc 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -18,31 +18,13 @@ export type StoreAssigner< context: TContext, event: TEvent, enq: EnqueueObject -) => TContext; -export type StoreCompleteAssigner< - TContext, - TEvent extends EventObject, - TEmitted extends EventObject -> = (ctx: TContext, ev: TEvent, enq: EnqueueObject) => TContext; -export type StorePartialAssigner< - TContext, - TEvent extends EventObject, - K extends keyof TContext, - TEmitted extends EventObject -> = ( - ctx: TContext, - ev: TEvent, - enq: EnqueueObject -) => Partial[K]; -export type StorePropertyAssigner< - TContext, +) => TContext | void; + +export type StoreProducerAssigner< + TContext extends StoreContext, TEvent extends EventObject, TEmitted extends EventObject -> = { - [K in keyof TContext]?: - | TContext[K] - | StorePartialAssigner; -}; +> = (context: TContext, event: TEvent, enq: EnqueueObject) => void; export type Snapshot = | { diff --git a/packages/xstate-store/test/fromStore.test.ts b/packages/xstate-store/test/fromStore.test.ts index d1d7e2349e..1cacf94f8c 100644 --- a/packages/xstate-store/test/fromStore.test.ts +++ b/packages/xstate-store/test/fromStore.test.ts @@ -25,10 +25,16 @@ describe('fromStore', () => { const storeLogic = fromStore({ context: (count: number) => ({ count }), on: { - inc: { - count: (ctx, ev: { by: number }) => { - return ctx.count + ev.by; - } + // inc: { + // count: (ctx, ev: { by: number }) => { + // return ctx.count + ev.by; + // } + // } + inc: (ctx, ev: { by: number }) => { + return { + ...ctx, + count: ctx.count + ev.by + }; } } }); @@ -53,11 +59,12 @@ describe('fromStore', () => { }, context: (count: number) => ({ count }), on: { - inc: { - count: (ctx, ev: { by: number }, enq) => { - enq.emit({ type: 'increased', upBy: ev.by }); - return ctx.count + ev.by; - } + inc: (ctx, ev: { by: number }, enq) => { + enq.emit({ type: 'increased', upBy: ev.by }); + return { + ...ctx, + count: ctx.count + ev.by + }; } } }); diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index 2b286be72d..b1d1a1d9c5 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -147,17 +147,16 @@ it('createStoreWithProducer(…) works with an immer producer (object API)', () }); it('createStoreWithProducer(…) infers the context type properly with a producer', () => { - const store = createStoreWithProducer( - produce, - { + const store = createStoreWithProducer(produce, { + context: { count: 0 }, - { + on: { inc: (ctx, ev: { by: number }) => { ctx.count += ev.by; } } - ); + }); store.getSnapshot().context satisfies { count: number }; }); From 4d2ddf0773c512e1799f9fff2613ceea05689829 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Tue, 21 Jan 2025 21:29:29 -0500 Subject: [PATCH 04/25] Enhance store functionality by introducing effects in event handling - Added `StoreEffect` type to support both emitted events and side effects. - Updated `createStoreTransition` to return effects instead of emitted events. - Modified `receive` function to handle effects, executing functions or emitting events accordingly. - Added a test case to verify that effects can be enqueued and executed after state updates. This change improves the flexibility of the store's event handling mechanism. --- .changeset/great-candles-rule.md | 5 ++++ packages/xstate-store/src/store.ts | 28 ++++++++++++------- packages/xstate-store/src/types.ts | 3 +++ packages/xstate-store/test/store.test.ts | 34 ++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 .changeset/great-candles-rule.md diff --git a/.changeset/great-candles-rule.md b/.changeset/great-candles-rule.md new file mode 100644 index 0000000000..04b6768cb9 --- /dev/null +++ b/.changeset/great-candles-rule.md @@ -0,0 +1,5 @@ +--- +'@xstate/store': major +--- + +Add `enq.effect(…)` diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 33bd54bc8e..e909a538c7 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -10,6 +10,7 @@ import { Store, StoreAssigner, StoreContext, + StoreEffect, StoreInspectionEvent, StoreProducerAssigner, StoreSnapshot @@ -91,8 +92,8 @@ function createStoreCore< const transition = createStoreTransition(transitions, producer); function receive(event: StoreEvent) { - let emitted: TEmitted[]; - [currentSnapshot, emitted] = transition(currentSnapshot, event); + let effects: StoreEffect[]; + [currentSnapshot, effects] = transition(currentSnapshot, event); inspectionObservers.get(store)?.forEach((observer) => { observer.next?.({ @@ -106,7 +107,13 @@ function createStoreCore< observers?.forEach((o) => o.next?.(currentSnapshot)); - emitted.forEach(emit); + for (const effect of effects) { + if (typeof effect === 'function') { + effect(); + } else { + emit(effect); + } + } } const store: Store = { @@ -347,20 +354,23 @@ export function createStoreTransition< return ( snapshot: StoreSnapshot, event: ExtractEventsFromPayloadMap - ): [StoreSnapshot, TEmitted[]] => { + ): [StoreSnapshot, StoreEffect[]] => { type StoreEvent = ExtractEventsFromPayloadMap; let currentContext = snapshot.context; const assigner = transitions?.[event.type as StoreEvent['type']]; - const emitted: TEmitted[] = []; + const effects: StoreEffect[] = []; - const enqueue = { + const enqueue: EnqueueObject = { emit: (ev: TEmitted) => { - emitted.push(ev); + effects.push(ev); + }, + effect: (fn) => { + effects.push(fn); } }; if (!assigner) { - return [snapshot, emitted]; + return [snapshot, effects]; } if (typeof assigner === 'function') { @@ -399,7 +409,7 @@ export function createStoreTransition< currentContext = Object.assign({}, currentContext, partialUpdate); } - return [{ ...snapshot, context: currentContext }, emitted]; + return [{ ...snapshot, context: currentContext }, effects]; }; } diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index 99a0a764cc..e2bc23cef3 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -8,8 +8,11 @@ export type Recipe = (state: T) => TReturn; export type EnqueueObject = { emit: (ev: TEmitted) => void; + effect: (fn: () => void) => void; }; +export type StoreEffect = (() => void) | TEmitted; + export type StoreAssigner< TContext extends StoreContext, TEvent extends EventObject, diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index b1d1a1d9c5..d8e93b8fc8 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -352,3 +352,37 @@ it('emitted events occur after the snapshot is updated', () => { store.send({ type: 'inc' }); }); + +it('effects can be enqueued', async () => { + const store = createStore({ + context: { + count: 0 + }, + on: { + inc: (ctx, _, enq) => { + enq.effect(() => { + setTimeout(() => { + store.send({ type: 'dec' }); + }, 5); + }); + + return { + ...ctx, + count: ctx.count + 1 + }; + }, + dec: (ctx) => ({ + ...ctx, + count: ctx.count - 1 + }) + } + }); + + store.send({ type: 'inc' }); + + expect(store.getSnapshot().context.count).toEqual(1); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(store.getSnapshot().context.count).toEqual(0); +}); From d55e9635e3a006c29615b5f2601e9cdfcbe267e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 22 Jan 2025 11:56:32 +0100 Subject: [PATCH 05/25] =?UTF-8?q?use=20overload=20trick=20=F0=9F=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/xstate-store/src/store.ts | 73 ++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index e909a538c7..6bdd8eed38 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -215,6 +215,33 @@ export type TransitionsFromEventPayloadMap< >; }; +type CreateStoreParameterTypes< + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TTypes extends { emitted?: EventObject } +> = [ + definition: { + context: TContext; + on: { + [K in keyof TEventPayloadMap & string]: StoreAssigner< + NoInfer, + { type: K } & TEventPayloadMap[K], + Cast + >; + }; + } & { types?: TTypes } +]; + +type CreateStoreReturnType< + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TTypes extends { emitted?: EventObject } +> = Store< + TContext, + ExtractEventsFromPayloadMap, + Cast +>; + /** * Creates a **store** that has its own internal state and can be sent events * that update its internal state based on transitions. @@ -244,30 +271,40 @@ export type TransitionsFromEventPayloadMap< * // Logs { context: { count: 5 }, status: 'active', ... } * ``` */ -export function createStore< +function _createStore< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, TTypes extends { emitted?: EventObject } ->({ - context, - on -}: { - context: TContext; - on: { - [K in keyof TEventPayloadMap & string]: StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - >; - }; -} & { types?: TTypes }): Store< - TContext, - ExtractEventsFromPayloadMap, - Cast -> { +>( + ...[{ context, on }]: CreateStoreParameterTypes< + TContext, + TEventPayloadMap, + TTypes + > +): CreateStoreReturnType { return createStoreCore(context, on); } +export const createStore: { + // those overloads are exactly the same, we only duplicate them so TypeScript can: + // 1. assign contextual parameter types during inference attempt for the first overload when the source object is still context-sensitive and often non-inferrable + // 2. infer correctly during inference attempt for the second overload when the parameter types are already "known" + < + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TTypes extends { emitted?: EventObject } + >( + ...args: CreateStoreParameterTypes + ): CreateStoreReturnType; + < + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TTypes extends { emitted?: EventObject } + >( + ...args: CreateStoreParameterTypes + ): CreateStoreReturnType; +} = _createStore; + /** * Creates a `Store` with a provided producer (such as Immer's `producer(…)` A * store has its own internal state and can receive events. From 73c9489cba15a5e853a86ce37ebb418564d79be6 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Wed, 22 Jan 2025 21:14:06 -0500 Subject: [PATCH 06/25] Fix fromStore --- packages/xstate-store/src/fromStore.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/xstate-store/src/fromStore.ts b/packages/xstate-store/src/fromStore.ts index 70908aec08..179605a32d 100644 --- a/packages/xstate-store/src/fromStore.ts +++ b/packages/xstate-store/src/fromStore.ts @@ -129,9 +129,15 @@ export function fromStore< const transition = createStoreTransition(transitionsObj); return { transition: (snapshot, event, actorScope) => { - const [nextSnapshot, emittedEvents] = transition(snapshot, event); + const [nextSnapshot, effects] = transition(snapshot, event); - emittedEvents.forEach(actorScope.emit); + for (const effect of effects) { + if (typeof effect === 'function') { + effect(); + } else { + actorScope.emit(effect); + } + } return nextSnapshot; }, From 3d2cded75c86ac0cc0d3e5296c6f98ee27818a45 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 25 Jan 2025 08:41:33 -0500 Subject: [PATCH 07/25] Update changesets --- .changeset/five-walls-approve.md | 24 +++++++++++++++++++++++- .changeset/great-candles-rule.md | 21 ++++++++++++++++++++- .changeset/spotty-moose-joke.md | 25 ++++++++++++++++++++++++- .changeset/thick-paws-invite.md | 16 +++++++++++++++- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/.changeset/five-walls-approve.md b/.changeset/five-walls-approve.md index 83410651b0..9193469d5c 100644 --- a/.changeset/five-walls-approve.md +++ b/.changeset/five-walls-approve.md @@ -2,4 +2,26 @@ '@xstate/store': major --- -`createStore` only takes a single { context, on } argument now +The `createStore` function now only accepts a single configuration object argument. This is a breaking change that simplifies the API and aligns with the configuration pattern used throughout XState. + +```ts +// Before +// createStore( +// { +// count: 0 +// }, +// { +// increment: (context) => ({ count: context.count + 1 }) +// } +// ); + +// After +createStore({ + context: { + count: 0 + }, + on: { + increment: (context) => ({ count: context.count + 1 }) + } +}); +``` diff --git a/.changeset/great-candles-rule.md b/.changeset/great-candles-rule.md index 04b6768cb9..c3233a0b2d 100644 --- a/.changeset/great-candles-rule.md +++ b/.changeset/great-candles-rule.md @@ -2,4 +2,23 @@ '@xstate/store': major --- -Add `enq.effect(…)` +You can now enqueue effects in state transitions. + +```ts +const store = createStore({ + context: { + count: 0 + }, + on: { + incrementDelayed: (context, event, enq) => { + enq.effect(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + store.send({ type: 'increment' }); + }); + + return context; + }, + increment: (context) => ({ count: context.count + 1 }) + } +}); +``` diff --git a/.changeset/spotty-moose-joke.md b/.changeset/spotty-moose-joke.md index 1a706ce688..cf76b09472 100644 --- a/.changeset/spotty-moose-joke.md +++ b/.changeset/spotty-moose-joke.md @@ -2,4 +2,27 @@ '@xstate/store': major --- -createStoreWithProducer now only accepts a single config object +The `createStoreWithProducer(…)` function now only accepts two arguments: a `producer` and a config (`{ context, on }`) object. + +```ts +// Before +// createStoreWithProducer( +// producer, +// { +// count: 0 +// }, +// { +// increment: (context) => ({ count: context.count + 1 }) +// } +// ); + +// After +createStoreWithProducer(producer, { + context: { + count: 0 + }, + on: { + increment: (context) => ({ count: context.count + 1 }) + } +}); +``` diff --git a/.changeset/thick-paws-invite.md b/.changeset/thick-paws-invite.md index 73f1274018..ea9cd7668e 100644 --- a/.changeset/thick-paws-invite.md +++ b/.changeset/thick-paws-invite.md @@ -2,4 +2,18 @@ '@xstate/store': major --- -Only complete assigners can now be used +Only complete assigner functions that replace the `context` fully are supported. This is a breaking change that simplifies the API and provides more type safety. + +```diff +const store = createStore({ + context: { + items: [], + count: 0 + }, + on: { +- increment: { count: (context) => context.count + 1 } +- increment: (context) => ({ count: context.count + 1 }) ++ increment: (context) => ({ ...context, count: context.count + 1 }) + } +}) +``` From cfe28e66597470c5100e918d1f24cc8ca00751cc Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 25 Jan 2025 09:16:00 -0500 Subject: [PATCH 08/25] Add support for type parameters --- packages/xstate-store/src/store.ts | 19 +++++++----- packages/xstate-store/test/types.test.tsx | 38 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 6bdd8eed38..65e6be05fc 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -289,13 +289,18 @@ export const createStore: { // those overloads are exactly the same, we only duplicate them so TypeScript can: // 1. assign contextual parameter types during inference attempt for the first overload when the source object is still context-sensitive and often non-inferrable // 2. infer correctly during inference attempt for the second overload when the parameter types are already "known" - < - TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap, - TTypes extends { emitted?: EventObject } - >( - ...args: CreateStoreParameterTypes - ): CreateStoreReturnType; + (definition: { + context: TContext; + on: { + [K in TEvent['type']]?: StoreAssigner< + NoInfer, + { type: K } & TEvent, + TEvent + >; + }; + types?: never; + }): Store; + < TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, diff --git a/packages/xstate-store/test/types.test.tsx b/packages/xstate-store/test/types.test.tsx index 6cd59d3d35..d448fc83a2 100644 --- a/packages/xstate-store/test/types.test.tsx +++ b/packages/xstate-store/test/types.test.tsx @@ -107,3 +107,41 @@ describe('emitted', () => { ); }); }); + +describe('type parameters', () => { + it('type parameters can be provided', () => { + createStore< + { count: number }, + | { type: 'increment'; by: number } + | { type: 'decrement' } + | { type: 'other' } + >({ + context: { + count: 0 + }, + on: { + increment: (ctx, ev) => { + ev.by satisfies number; + + // @ts-expect-error + ev.by satisfies string; + + return { ...ctx, count: ctx.count + ev.by }; + }, + + // @ts-expect-error + whatever: (ctx) => ({ ...ctx, count: 1 }), + + decrement: (ctx) => { + // @ts-expect-error + ctx.whatever; + }, + + // @ts-expect-error + other: () => ({ + count: 'whatever' + }) + } + }); + }); +}); From c0efebf87aade299c19feec048c693723e82dc77 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 26 Jan 2025 10:59:03 -0500 Subject: [PATCH 09/25] Revert "Add support for type parameters" This reverts commit cfe28e66597470c5100e918d1f24cc8ca00751cc. --- packages/xstate-store/src/store.ts | 19 +++++------- packages/xstate-store/test/types.test.tsx | 38 ----------------------- 2 files changed, 7 insertions(+), 50 deletions(-) diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 65e6be05fc..6bdd8eed38 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -289,18 +289,13 @@ export const createStore: { // those overloads are exactly the same, we only duplicate them so TypeScript can: // 1. assign contextual parameter types during inference attempt for the first overload when the source object is still context-sensitive and often non-inferrable // 2. infer correctly during inference attempt for the second overload when the parameter types are already "known" - (definition: { - context: TContext; - on: { - [K in TEvent['type']]?: StoreAssigner< - NoInfer, - { type: K } & TEvent, - TEvent - >; - }; - types?: never; - }): Store; - + < + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TTypes extends { emitted?: EventObject } + >( + ...args: CreateStoreParameterTypes + ): CreateStoreReturnType; < TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, diff --git a/packages/xstate-store/test/types.test.tsx b/packages/xstate-store/test/types.test.tsx index d448fc83a2..6cd59d3d35 100644 --- a/packages/xstate-store/test/types.test.tsx +++ b/packages/xstate-store/test/types.test.tsx @@ -107,41 +107,3 @@ describe('emitted', () => { ); }); }); - -describe('type parameters', () => { - it('type parameters can be provided', () => { - createStore< - { count: number }, - | { type: 'increment'; by: number } - | { type: 'decrement' } - | { type: 'other' } - >({ - context: { - count: 0 - }, - on: { - increment: (ctx, ev) => { - ev.by satisfies number; - - // @ts-expect-error - ev.by satisfies string; - - return { ...ctx, count: ctx.count + ev.by }; - }, - - // @ts-expect-error - whatever: (ctx) => ({ ...ctx, count: 1 }), - - decrement: (ctx) => { - // @ts-expect-error - ctx.whatever; - }, - - // @ts-expect-error - other: () => ({ - count: 'whatever' - }) - } - }); - }); -}); From 8528460dc9d961a6c1351bfba145a08b935b22c7 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 26 Jan 2025 13:46:41 -0500 Subject: [PATCH 10/25] Refactor store type parameters to improve type safety and flexibility - Updated `createStore` and `createStoreWithProducer` to use more explicit type parameters - Replaced `types: { emitted }` with separate type parameters for context, event payloads, and emitted events - Removed `Cast` import and simplified type definitions - Updated test cases to use new type parameter approach - Added `EventMap` type to support event type mapping --- .changeset/big-schools-hang.md | 37 ++++++++++++ packages/xstate-store/src/store.ts | 39 +++++++------ packages/xstate-store/src/types.ts | 4 ++ packages/xstate-store/test/store.test.ts | 31 +++++----- packages/xstate-store/test/types.test.tsx | 70 ++++++++++++++--------- 5 files changed, 120 insertions(+), 61 deletions(-) create mode 100644 .changeset/big-schools-hang.md diff --git a/.changeset/big-schools-hang.md b/.changeset/big-schools-hang.md new file mode 100644 index 0000000000..900309da76 --- /dev/null +++ b/.changeset/big-schools-hang.md @@ -0,0 +1,37 @@ +--- +'@xstate/store': major +--- + +Type parameters should now be explicitly provided to `createStore` and `createStoreWithProducer`. For sent and emitted events, the an `EventPayloadMap` should be provided, which is a map of event types to their payloads. + +```ts +createStore< + // Context + { + count: number; + }, + // Sent events + { + inc: { + by: number; + }; + }, + // Emitted events + { + increased: { + upBy: number; + }; + } +>({ + context: { count: 0 }, + on: { + inc: (ctx, event, enq) => { + enq.emit({ type: 'increased', upBy: event.by }); + return { + ...ctx, + count: ctx.count + event.by + }; + } + } +}); +``` diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 6bdd8eed38..107602efa8 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -1,5 +1,4 @@ import { - Cast, EnqueueObject, EventObject, EventPayloadMap, @@ -218,7 +217,7 @@ export type TransitionsFromEventPayloadMap< type CreateStoreParameterTypes< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TTypes extends { emitted?: EventObject } + TEmitted extends EventPayloadMap > = [ definition: { context: TContext; @@ -226,20 +225,20 @@ type CreateStoreParameterTypes< [K in keyof TEventPayloadMap & string]: StoreAssigner< NoInfer, { type: K } & TEventPayloadMap[K], - Cast + ExtractEventsFromPayloadMap >; }; - } & { types?: TTypes } + } ]; type CreateStoreReturnType< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TTypes extends { emitted?: EventObject } + TEmitted extends EventPayloadMap > = Store< TContext, ExtractEventsFromPayloadMap, - Cast + ExtractEventsFromPayloadMap >; /** @@ -274,14 +273,14 @@ type CreateStoreReturnType< function _createStore< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TTypes extends { emitted?: EventObject } + TEmitted extends EventPayloadMap >( ...[{ context, on }]: CreateStoreParameterTypes< TContext, TEventPayloadMap, - TTypes + TEmitted > -): CreateStoreReturnType { +): CreateStoreReturnType { return createStoreCore(context, on); } @@ -292,17 +291,17 @@ export const createStore: { < TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TTypes extends { emitted?: EventObject } + TEmitted extends EventPayloadMap >( - ...args: CreateStoreParameterTypes - ): CreateStoreReturnType; + ...args: CreateStoreParameterTypes + ): CreateStoreReturnType; < TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TTypes extends { emitted?: EventObject } + TEmitted extends EventObject >( - ...args: CreateStoreParameterTypes - ): CreateStoreReturnType; + ...args: CreateStoreParameterTypes + ): CreateStoreReturnType; } = _createStore; /** @@ -338,7 +337,7 @@ export const createStore: { export function createStoreWithProducer< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventObject = EventObject + TEmittedPayloadMap extends EventPayloadMap >( producer: NoInfer< (context: TContext, recipe: (context: TContext) => void) => TContext @@ -349,11 +348,15 @@ export function createStoreWithProducer< [K in keyof TEventPayloadMap & string]: ( context: NoInfer, event: { type: K } & TEventPayloadMap[K], - enqueue: EnqueueObject + enqueue: EnqueueObject> ) => void; }; } -): Store, TEmitted> { +): Store< + TContext, + ExtractEventsFromPayloadMap, + ExtractEventsFromPayloadMap +> { return createStoreCore(config.context, config.on, producer); } diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index e2bc23cef3..d51ff976a9 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -285,3 +285,7 @@ export type ActorRefLike = { export type Prop = K extends keyof T ? T[K] : never; export type Cast = A extends B ? A : B; + +export type EventMap = { + [K in TEvent['type']]: TEvent & { type: K }; +}; diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index d8e93b8fc8..d6b821172f 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -258,12 +258,11 @@ it('inspection with @statelyai/inspect typechecks correctly', () => { }); it('emitted events can be subscribed to', () => { - const store = createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } - }, + const store = createStore< + { count: number }, + { inc: {} }, + { increased: { upBy: number } } + >({ context: { count: 0 }, @@ -289,12 +288,11 @@ it('emitted events can be subscribed to', () => { }); it('emitted events can be unsubscribed to', () => { - const store = createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } - }, + const store = createStore< + { count: number }, + { inc: {} }, + { increased: { upBy: number } } + >({ context: { count: 0 }, @@ -323,10 +321,11 @@ it('emitted events can be unsubscribed to', () => { }); it('emitted events occur after the snapshot is updated', () => { - const store = createStore({ - types: { - emitted: {} as { type: 'increased'; upBy: number } - }, + const store = createStore< + { count: number }, + { inc: {} }, + { increased: { upBy: number } } + >({ context: { count: 0 }, diff --git a/packages/xstate-store/test/types.test.tsx b/packages/xstate-store/test/types.test.tsx index 6cd59d3d35..877b4babf7 100644 --- a/packages/xstate-store/test/types.test.tsx +++ b/packages/xstate-store/test/types.test.tsx @@ -2,12 +2,16 @@ import { createStore } from '../src/index'; describe('emitted', () => { it('can emit a known event', () => { - createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } + createStore< + {}, + { + inc: { upBy: number }; }, + { + increased: { upBy: number }; + decreased: { downBy: number }; + } + >({ context: {}, on: { inc: (ctx, _, enq) => { @@ -19,12 +23,16 @@ describe('emitted', () => { }); it("can't emit an unknown event", () => { - createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } + createStore< + {}, + { + inc: { upBy: number }; }, + { + increased: { upBy: number }; + decreased: { downBy: number }; + } + >({ context: {}, on: { inc: (ctx, _, enq) => { @@ -39,12 +47,16 @@ describe('emitted', () => { }); it("can't emit a known event with wrong payload", () => { - createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } + createStore< + {}, + { + inc: { upBy: number }; }, + { + increased: { upBy: number }; + decreased: { downBy: number }; + } + >({ context: {}, on: { inc: (ctx, _, enq) => { @@ -74,12 +86,14 @@ describe('emitted', () => { }); it('can subscribe to a known event', () => { - const store = createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } - }, + const store = createStore< + {}, + {}, + { + increased: { upBy: number }; + decreased: { downBy: number }; + } + >({ context: {}, on: {} }); @@ -90,12 +104,14 @@ describe('emitted', () => { }); it("can can't subscribe to a unknown event", () => { - const store = createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } - }, + const store = createStore< + {}, + {}, + { + increased: { upBy: number }; + decreased: { downBy: number }; + } + >({ context: {}, on: {} }); From 008badf268ee4610b8ba0a0a1d419132cd331e4f Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 26 Jan 2025 14:04:30 -0500 Subject: [PATCH 11/25] Add trigger + test + changeset --- .changeset/quick-bears-swim.md | 24 +++++++++ packages/xstate-store/src/store.ts | 17 +++++- packages/xstate-store/src/types.ts | 23 +++++++++ packages/xstate-store/test/store.test.ts | 66 +++++++++++++++++++++++- 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 .changeset/quick-bears-swim.md diff --git a/.changeset/quick-bears-swim.md b/.changeset/quick-bears-swim.md new file mode 100644 index 0000000000..830fc10e16 --- /dev/null +++ b/.changeset/quick-bears-swim.md @@ -0,0 +1,24 @@ +--- +'@xstate/store': minor +--- + +Added `store.trigger` API for sending events with a fluent interface: + +```ts +const store = createStore({ + context: { count: 0 }, + on: { + increment: (ctx, event: { by: number }) => ({ + count: ctx.count + event.by + }) + } +}); + +// Instead of manually constructing event objects: +store.send({ type: 'increment', by: 5 }); + +// You can now use the fluent trigger API: +store.trigger.increment({ by: 5 }); +``` + +The `trigger` API provides full type safety for event names and payloads, making it easier and safer to send events to the store. diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 107602efa8..47fc8518d8 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -194,9 +194,24 @@ function createStoreCore< return inspectionObservers.get(store)?.delete(observer); } }; - } + }, + trigger: {} as any }; + (store as any).trigger = new Proxy( + {} as Store['trigger'], + { + get: (_, eventType: string) => { + return (payload: any) => { + store.send({ + type: eventType, + ...payload + }); + }; + } + } + ); + return store; } diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index d51ff976a9..bd3e6d7721 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -90,8 +90,31 @@ export interface Store< ev: Compute ) => void ) => Subscription; + /** + * A proxy object that allows you to send events to the store without manually + * constructing event objects. + * + * @example + * + * ```ts + * // Instead of: + * store.send({ type: 'increment', by: 1 }); + * + * // You can trigger the event: + * store.trigger.increment({ by: 1 }); + * ``` + */ + trigger: { + [K in TEvent['type'] & string]: IsEmptyObject< + Omit<{ type: K } & TEvent, 'type'> + > extends true + ? () => Omit<{ type: K } & TEvent, 'type'> + : (eventPayload: Omit<{ type: K } & TEvent, 'type'>) => void; + }; } +export type IsEmptyObject = T extends Record ? true : false; + export type AnyStore = Store; export type Compute = { [K in keyof A]: A[K] }; diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index d6b821172f..45082522b3 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -1,5 +1,5 @@ import { produce } from 'immer'; -import { createStore, createStoreWithProducer } from '../src/index.ts'; +import { Compute, createStore, createStoreWithProducer } from '../src/index.ts'; import { createBrowserInspector } from '@statelyai/inspect'; it('updates a store with an event without mutating original context', () => { @@ -385,3 +385,67 @@ it('effects can be enqueued', async () => { expect(store.getSnapshot().context.count).toEqual(0); }); + +describe('store.trigger', () => { + it('should allow triggering events with a fluent API', () => { + const store = createStore({ + context: { count: 0 }, + on: { + increment: (ctx, event: { by: number }) => ({ + count: ctx.count + event.by + }) + } + }); + + store.trigger.increment({ by: 5 }); + + expect(store.getSnapshot().context.count).toBe(5); + }); + + it('should provide type safety for event payloads', () => { + const store = createStore({ + context: { count: 0 }, + on: { + increment: (ctx, event: { by: number }) => ({ + count: ctx.count + event.by + }), + reset: () => ({ count: 0 }) + } + }); + + // @ts-expect-error - missing required 'by' property + store.trigger.increment({}); + + // @ts-expect-error - extra property not allowed + store.trigger.increment({ by: 1, extra: true }); + + // @ts-expect-error - unknown event + store.trigger.unknown({}); + + // Valid usage with no payload + store.trigger.reset(); + + // Valid usage with payload + store.trigger.increment({ by: 1 }); + }); + + it('should be equivalent to store.send', () => { + const store = createStore({ + context: { count: 0 }, + on: { + increment: (ctx, event: { by: number }) => ({ + count: ctx.count + event.by + }) + } + }); + + const sendSpy = jest.spyOn(store, 'send'); + + store.trigger.increment({ by: 5 }); + + expect(sendSpy).toHaveBeenCalledWith({ + type: 'increment', + by: 5 + }); + }); +}); From ad23c06fc32e3cdfe29e0da045fcd9c4d5528b38 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 30 Jan 2025 19:21:29 -0500 Subject: [PATCH 12/25] Add emits --- .changeset/wise-bikes-leave.md | 21 ++++++ packages/xstate-store/src/fromStore.ts | 31 +++++---- packages/xstate-store/src/store.ts | 18 ++++-- packages/xstate-store/src/types.ts | 8 ++- packages/xstate-store/test/fromStore.test.ts | 8 +-- packages/xstate-store/test/store.test.ts | 34 +++++----- packages/xstate-store/test/types.test.tsx | 68 ++++++-------------- 7 files changed, 95 insertions(+), 93 deletions(-) create mode 100644 .changeset/wise-bikes-leave.md diff --git a/.changeset/wise-bikes-leave.md b/.changeset/wise-bikes-leave.md new file mode 100644 index 0000000000..4da9c4de0b --- /dev/null +++ b/.changeset/wise-bikes-leave.md @@ -0,0 +1,21 @@ +--- +'@xstate/store': major +--- + +Emitted event types are now specified in functions on the `emits` property of the store definition: + +```ts +const store = createStore({ + // … + emits: { + increased: (payload: { upBy: number }) => {} + }, + on: { + inc: (ctx, ev: { by: number }, enq) => { + enq.emit.increased({ upBy: ev.by }); + + // … + } + } +}); +``` diff --git a/packages/xstate-store/src/fromStore.ts b/packages/xstate-store/src/fromStore.ts index 179605a32d..4c436a8699 100644 --- a/packages/xstate-store/src/fromStore.ts +++ b/packages/xstate-store/src/fromStore.ts @@ -60,23 +60,26 @@ export function fromStore< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, TInput, - TTypes extends { emitted?: EventObject } ->( - config: { - context: ((input: TInput) => TContext) | TContext; - on: { - [K in keyof TEventPayloadMap & string]: StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - >; - }; - } & { types?: TTypes } -): StoreLogic< + TEmitted extends EventPayloadMap +>(config: { + context: ((input: TInput) => TContext) | TContext; + on: { + [K in keyof TEventPayloadMap & string]: StoreAssigner< + NoInfer, + { type: K } & TEventPayloadMap[K], + ExtractEventsFromPayloadMap + >; + }; + emits?: { + [K in keyof TEventPayloadMap & string]: ( + payload: { type: K } & TEventPayloadMap[K] + ) => void; + }; +}): StoreLogic< TContext, ExtractEventsFromPayloadMap, TInput, - TTypes['emitted'] extends EventObject ? TTypes['emitted'] : EventObject + ExtractEventsFromPayloadMap >; export function fromStore< TContext extends StoreContext, diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 47fc8518d8..bd4a262c8e 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -236,6 +236,9 @@ type CreateStoreParameterTypes< > = [ definition: { context: TContext; + emits?: { + [K in keyof TEmitted & string]: (payload: TEmitted[K]) => void; + }; on: { [K in keyof TEventPayloadMap & string]: StoreAssigner< NoInfer, @@ -313,7 +316,7 @@ export const createStore: { < TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventObject + TEmitted extends EventPayloadMap >( ...args: CreateStoreParameterTypes ): CreateStoreReturnType; @@ -416,9 +419,16 @@ export function createStoreTransition< const effects: StoreEffect[] = []; const enqueue: EnqueueObject = { - emit: (ev: TEmitted) => { - effects.push(ev); - }, + emit: new Proxy({} as any, { + get: (_, eventType: string) => { + return (payload: any) => { + effects.push({ + type: eventType, + ...payload + }); + }; + } + }), effect: (fn) => { effects.push(fn); } diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index bd3e6d7721..58f654ae8c 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -6,8 +6,12 @@ export type ExtractEventsFromPayloadMap = Values<{ export type Recipe = (state: T) => TReturn; -export type EnqueueObject = { - emit: (ev: TEmitted) => void; +export type EnqueueObject = { + emit: { + [K in TEmittedEvent['type']]: ( + payload: Omit + ) => void; + }; effect: (fn: () => void) => void; }; diff --git a/packages/xstate-store/test/fromStore.test.ts b/packages/xstate-store/test/fromStore.test.ts index 1cacf94f8c..f89c1be159 100644 --- a/packages/xstate-store/test/fromStore.test.ts +++ b/packages/xstate-store/test/fromStore.test.ts @@ -54,13 +54,13 @@ describe('fromStore', () => { const spy = jest.fn(); const storeLogic = fromStore({ - types: { - emitted: {} as { type: 'increased'; upBy: number } - }, context: (count: number) => ({ count }), + emits: { + increased: (_: { upBy: number }) => {} + }, on: { inc: (ctx, ev: { by: number }, enq) => { - enq.emit({ type: 'increased', upBy: ev.by }); + enq.emit.increased({ upBy: ev.by }); return { ...ctx, count: ctx.count + ev.by diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index 45082522b3..c13ecefc10 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -258,18 +258,16 @@ it('inspection with @statelyai/inspect typechecks correctly', () => { }); it('emitted events can be subscribed to', () => { - const store = createStore< - { count: number }, - { inc: {} }, - { increased: { upBy: number } } - >({ + const store = createStore({ context: { count: 0 }, + emits: { + increased: (a: { upBy: number }) => {} + }, on: { inc: (ctx, _, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); - + enq.emit.increased({ upBy: 1 }); return { ...ctx, count: ctx.count + 1 @@ -288,17 +286,16 @@ it('emitted events can be subscribed to', () => { }); it('emitted events can be unsubscribed to', () => { - const store = createStore< - { count: number }, - { inc: {} }, - { increased: { upBy: number } } - >({ + const store = createStore({ context: { count: 0 }, + emits: { + increased: (_: { upBy: number }) => {} + }, on: { inc: (ctx, _, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); + enq.emit.increased({ upBy: 1 }); return { ...ctx, @@ -321,17 +318,16 @@ it('emitted events can be unsubscribed to', () => { }); it('emitted events occur after the snapshot is updated', () => { - const store = createStore< - { count: number }, - { inc: {} }, - { increased: { upBy: number } } - >({ + const store = createStore({ context: { count: 0 }, + emits: { + increased: (_: { upBy: number }) => {} + }, on: { inc: (ctx, _, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); + enq.emit.increased({ upBy: 1 }); return { ...ctx, diff --git a/packages/xstate-store/test/types.test.tsx b/packages/xstate-store/test/types.test.tsx index 877b4babf7..1e4a0c7281 100644 --- a/packages/xstate-store/test/types.test.tsx +++ b/packages/xstate-store/test/types.test.tsx @@ -2,20 +2,14 @@ import { createStore } from '../src/index'; describe('emitted', () => { it('can emit a known event', () => { - createStore< - {}, - { - inc: { upBy: number }; - }, - { - increased: { upBy: number }; - decreased: { downBy: number }; - } - >({ + createStore({ context: {}, + emits: { + increased: (_: { upBy: number }) => {} + }, on: { inc: (ctx, _, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); + enq.emit.increased({ upBy: 1 }); return ctx; } } @@ -23,23 +17,17 @@ describe('emitted', () => { }); it("can't emit an unknown event", () => { - createStore< - {}, - { - inc: { upBy: number }; - }, - { - increased: { upBy: number }; - decreased: { downBy: number }; - } - >({ + createStore({ context: {}, + emits: { + increased: (_: { upBy: number }) => {}, + decreased: (_: { downBy: number }) => {} + }, on: { inc: (ctx, _, enq) => { - enq.emit({ + enq.emit // @ts-expect-error - type: 'unknown' - }); + .unknown(); return ctx; } } @@ -47,21 +35,15 @@ describe('emitted', () => { }); it("can't emit a known event with wrong payload", () => { - createStore< - {}, - { - inc: { upBy: number }; - }, - { - increased: { upBy: number }; - decreased: { downBy: number }; - } - >({ + createStore({ context: {}, + emits: { + increased: (_: { upBy: number }) => {}, + decreased: (_: { downBy: number }) => {} + }, on: { inc: (ctx, _, enq) => { - enq.emit({ - type: 'increased', + enq.emit.increased({ // @ts-expect-error upBy: 'bazinga' }); @@ -71,20 +53,6 @@ describe('emitted', () => { }); }); - it('can emit an event when emitted events are unknown', () => { - createStore({ - context: {}, - on: { - inc: (ctx, _, enq) => { - enq.emit({ - type: 'unknown' - }); - return ctx; - } - } - }); - }); - it('can subscribe to a known event', () => { const store = createStore< {}, From babb74d462d68d6f279de964fc08d5f508af4378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 31 Jan 2025 23:35:59 +0100 Subject: [PATCH 13/25] Fixed TS issue --- packages/xstate-store/src/types.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index 58f654ae8c..b1baacee4f 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -8,9 +8,7 @@ export type Recipe = (state: T) => TReturn; export type EnqueueObject = { emit: { - [K in TEmittedEvent['type']]: ( - payload: Omit - ) => void; + [E in TEmittedEvent as E['type']]: (payload: Omit) => void; }; effect: (fn: () => void) => void; }; @@ -109,11 +107,11 @@ export interface Store< * ``` */ trigger: { - [K in TEvent['type'] & string]: IsEmptyObject< - Omit<{ type: K } & TEvent, 'type'> + [E in TEvent as E['type'] & string]: IsEmptyObject< + Omit > extends true - ? () => Omit<{ type: K } & TEvent, 'type'> - : (eventPayload: Omit<{ type: K } & TEvent, 'type'>) => void; + ? () => Omit + : (eventPayload: Omit) => void; }; } @@ -314,5 +312,5 @@ export type Prop = K extends keyof T ? T[K] : never; export type Cast = A extends B ? A : B; export type EventMap = { - [K in TEvent['type']]: TEvent & { type: K }; + [E in TEvent as E['type']]: E; }; From 610ca159c4c7e5f216f9f924016e34d11755f6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 31 Jan 2025 23:47:02 +0100 Subject: [PATCH 14/25] use correct types --- packages/xstate-store/src/fromStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/xstate-store/src/fromStore.ts b/packages/xstate-store/src/fromStore.ts index 4c436a8699..2497f3094f 100644 --- a/packages/xstate-store/src/fromStore.ts +++ b/packages/xstate-store/src/fromStore.ts @@ -71,8 +71,8 @@ export function fromStore< >; }; emits?: { - [K in keyof TEventPayloadMap & string]: ( - payload: { type: K } & TEventPayloadMap[K] + [K in keyof TEmitted & string]: ( + payload: { type: K } & TEmitted[K] ) => void; }; }): StoreLogic< From 80fe5932f9653da173be28508fe7b3f2db94bee2 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 1 Feb 2025 12:54:17 -0500 Subject: [PATCH 15/25] Remove changeset --- .changeset/big-schools-hang.md | 37 ---------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .changeset/big-schools-hang.md diff --git a/.changeset/big-schools-hang.md b/.changeset/big-schools-hang.md deleted file mode 100644 index 900309da76..0000000000 --- a/.changeset/big-schools-hang.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@xstate/store': major ---- - -Type parameters should now be explicitly provided to `createStore` and `createStoreWithProducer`. For sent and emitted events, the an `EventPayloadMap` should be provided, which is a map of event types to their payloads. - -```ts -createStore< - // Context - { - count: number; - }, - // Sent events - { - inc: { - by: number; - }; - }, - // Emitted events - { - increased: { - upBy: number; - }; - } ->({ - context: { count: 0 }, - on: { - inc: (ctx, event, enq) => { - enq.emit({ type: 'increased', upBy: event.by }); - return { - ...ctx, - count: ctx.count + event.by - }; - } - } -}); -``` From b325d685150f924c0cfda25abf1cab64addd489e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Sun, 2 Feb 2025 23:50:57 +0100 Subject: [PATCH 16/25] use util --- packages/xstate-store/test/store.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index c13ecefc10..75fe29f219 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -1,6 +1,7 @@ import { produce } from 'immer'; import { Compute, createStore, createStoreWithProducer } from '../src/index.ts'; import { createBrowserInspector } from '@statelyai/inspect'; +import { sleep } from '../../../scripts/jest-utils/index'; it('updates a store with an event without mutating original context', () => { const context = { count: 0 }; @@ -377,7 +378,7 @@ it('effects can be enqueued', async () => { expect(store.getSnapshot().context.count).toEqual(1); - await new Promise((resolve) => setTimeout(resolve, 10)); + await sleep(10); expect(store.getSnapshot().context.count).toEqual(0); }); From 0433e3d3c16c0ebdf8fd67c00ff2e95eb881de0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Sun, 2 Feb 2025 23:51:49 +0100 Subject: [PATCH 17/25] remove redundant test --- packages/xstate-store/test/store.test.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index 75fe29f219..ac96817378 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -162,21 +162,6 @@ it('createStoreWithProducer(…) infers the context type properly with a produce store.getSnapshot().context satisfies { count: number }; }); -it('createStoreWithProducer(…) infers the context type properly with a producer (object API)', () => { - const store = createStoreWithProducer(produce, { - context: { - count: 0 - }, - on: { - inc: (ctx, ev: { by: number }) => { - ctx.count += ev.by; - } - } - }); - - store.getSnapshot().context satisfies { count: number }; -}); - it('can be observed', () => { const store = createStore({ context: { From 0e53d88a1741428e37aaff0390d6e12779b0798e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Mon, 3 Feb 2025 00:00:10 +0100 Subject: [PATCH 18/25] tweak things --- packages/xstate-store/src/store.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index bd4a262c8e..5af036f905 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -195,12 +195,7 @@ function createStoreCore< } }; }, - trigger: {} as any - }; - - (store as any).trigger = new Proxy( - {} as Store['trigger'], - { + trigger: new Proxy({} as Store['trigger'], { get: (_, eventType: string) => { return (payload: any) => { store.send({ @@ -209,8 +204,8 @@ function createStoreCore< }); }; } - } - ); + }) + }; return store; } @@ -267,9 +262,6 @@ type CreateStoreReturnType< * * ```ts * const store = createStore({ - * types: { - * // ... - * }, * context: { count: 0 }, * on: { * inc: (context, event: { by: number }) => { From 0ad2815e3cb60077a2040380e9b39de106c318d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Mon, 3 Feb 2025 00:09:00 +0100 Subject: [PATCH 19/25] remove commented out code --- packages/xstate-store/test/fromStore.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/xstate-store/test/fromStore.test.ts b/packages/xstate-store/test/fromStore.test.ts index f89c1be159..edf41f4f41 100644 --- a/packages/xstate-store/test/fromStore.test.ts +++ b/packages/xstate-store/test/fromStore.test.ts @@ -25,11 +25,6 @@ describe('fromStore', () => { const storeLogic = fromStore({ context: (count: number) => ({ count }), on: { - // inc: { - // count: (ctx, ev: { by: number }) => { - // return ctx.count + ev.by; - // } - // } inc: (ctx, ev: { by: number }) => { return { ...ctx, From cb03154df084b72e8119ebea35f85c961d87deaa Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 2 Feb 2025 20:06:24 -0500 Subject: [PATCH 20/25] Quick typestates test --- packages/xstate-store/test/store.test.ts | 47 +++++++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index ac96817378..84e6cd7c36 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -1,7 +1,6 @@ import { produce } from 'immer'; import { Compute, createStore, createStoreWithProducer } from '../src/index.ts'; import { createBrowserInspector } from '@statelyai/inspect'; -import { sleep } from '../../../scripts/jest-utils/index'; it('updates a store with an event without mutating original context', () => { const context = { count: 0 }; @@ -162,6 +161,21 @@ it('createStoreWithProducer(…) infers the context type properly with a produce store.getSnapshot().context satisfies { count: number }; }); +it('createStoreWithProducer(…) infers the context type properly with a producer (object API)', () => { + const store = createStoreWithProducer(produce, { + context: { + count: 0 + }, + on: { + inc: (ctx, ev: { by: number }) => { + ctx.count += ev.by; + } + } + }); + + store.getSnapshot().context satisfies { count: number }; +}); + it('can be observed', () => { const store = createStore({ context: { @@ -363,7 +377,7 @@ it('effects can be enqueued', async () => { expect(store.getSnapshot().context.count).toEqual(1); - await sleep(10); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(store.getSnapshot().context.count).toEqual(0); }); @@ -431,3 +445,32 @@ describe('store.trigger', () => { }); }); }); + +it('works with typestates', () => { + type ContextStates = + | { + status: 'loading'; + data: null; + } + | { + status: 'success'; + data: string; + }; + + const store = createStore({ + context: { + status: 'loading', + data: null + } as ContextStates, + on: { + loaded: () => ({ + status: 'success' as const, + data: 'hello' + }), + loading: () => ({ + status: 'loading' as const, + data: null + }) + } + }); +}); From 3672932fdd4a3a9bdce0a401a2e19fff367390c3 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 8 Feb 2025 09:30:38 -0500 Subject: [PATCH 21/25] Update tests --- packages/xstate-store/src/types.ts | 6 ++-- packages/xstate-store/test/store.test.ts | 41 ++++++++++++++---------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index b1baacee4f..bde8ae7d84 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -99,10 +99,8 @@ export interface Store< * @example * * ```ts - * // Instead of: - * store.send({ type: 'increment', by: 1 }); - * - * // You can trigger the event: + * // Equivalent to: + * // store.send({ type: 'increment', by: 1 }); * store.trigger.increment({ by: 1 }); * ``` */ diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index 84e6cd7c36..2b4add8311 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -1,5 +1,5 @@ import { produce } from 'immer'; -import { Compute, createStore, createStoreWithProducer } from '../src/index.ts'; +import { createStore, createStoreWithProducer } from '../src/index.ts'; import { createBrowserInspector } from '@statelyai/inspect'; it('updates a store with an event without mutating original context', () => { @@ -26,7 +26,7 @@ it('updates a store with an event without mutating original context', () => { expect(context.count).toEqual(0); }); -it('can update context with a property assigner', () => { +it('can update context', () => { const store = createStore({ context: { count: 0, greeting: 'hello' }, on: { @@ -34,7 +34,7 @@ it('can update context with a property assigner', () => { ...ctx, count: ctx.count + 1 }), - updateBoth: (ctx) => ({ + updateBoth: () => ({ count: 42, greeting: 'hi' }) @@ -153,21 +153,10 @@ it('createStoreWithProducer(…) infers the context type properly with a produce }, on: { inc: (ctx, ev: { by: number }) => { - ctx.count += ev.by; - } - } - }); - - store.getSnapshot().context satisfies { count: number }; -}); + ctx satisfies { count: number }; + // @ts-expect-error + ctx satisfies { count: string }; -it('createStoreWithProducer(…) infers the context type properly with a producer (object API)', () => { - const store = createStoreWithProducer(produce, { - context: { - count: 0 - }, - on: { - inc: (ctx, ev: { by: number }) => { ctx.count += ev.by; } } @@ -192,6 +181,8 @@ it('can be observed', () => { const sub = store.subscribe((s) => counts.push(s.context.count)); + expect(counts).toEqual([]); + store.send({ type: 'inc' }); // 1 store.send({ type: 'inc' }); // 2 store.send({ type: 'inc' }); // 3 @@ -473,4 +464,20 @@ it('works with typestates', () => { }) } }); + + const context = store.getSnapshot().context; + + if (context.status === 'loading') { + context.data satisfies null; + // @ts-expect-error + context.data satisfies string; + } else { + context.status satisfies 'success'; + // @ts-expect-error + context.status satisfies 'loading'; + + context.data satisfies string; + // @ts-expect-error + context.data satisfies null; + } }); From 5935e0cb420c7dda6780533cfa7919c9317c8706 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 8 Feb 2025 09:46:24 -0500 Subject: [PATCH 22/25] Update fromStore --- .changeset/mean-taxis-jump.md | 17 ++++ packages/xstate-store/src/fromStore.ts | 90 +++----------------- packages/xstate-store/src/store.ts | 24 +++--- packages/xstate-store/src/types.ts | 2 +- packages/xstate-store/test/fromStore.test.ts | 19 ----- 5 files changed, 39 insertions(+), 113 deletions(-) create mode 100644 .changeset/mean-taxis-jump.md diff --git a/.changeset/mean-taxis-jump.md b/.changeset/mean-taxis-jump.md new file mode 100644 index 0000000000..54bd1c7420 --- /dev/null +++ b/.changeset/mean-taxis-jump.md @@ -0,0 +1,17 @@ +--- +'@xstate/store': major +--- + +The `fromStore(config)` function now only supports a single config object argument. + +```ts +const storeLogic = fromStore({ + context: (input: { initialCount: number }) => ({ count: input.initialCount }), + on: { + inc: (ctx, ev: { by: number }) => ({ + ...ctx, + count: ctx.count + ev.by + }) + } +}); +``` diff --git a/packages/xstate-store/src/fromStore.ts b/packages/xstate-store/src/fromStore.ts index 2497f3094f..551a48335c 100644 --- a/packages/xstate-store/src/fromStore.ts +++ b/packages/xstate-store/src/fromStore.ts @@ -1,4 +1,4 @@ -import { ActorLogic, Cast } from 'xstate'; +import { ActorLogic } from 'xstate'; import { createStoreTransition, TransitionsFromEventPayloadMap } from './store'; import { EventPayloadMap, @@ -6,7 +6,7 @@ import { Snapshot, StoreSnapshot, EventObject, - ExtractEventsFromPayloadMap, + ExtractEvents, StoreAssigner } from './types'; @@ -17,34 +17,6 @@ type StoreLogic< TEmitted extends EventObject > = ActorLogic, TEvent, TInput, any, TEmitted>; -/** - * An actor logic creator which creates store [actor - * logic](https://stately.ai/docs/actors#actor-logic) for use with XState. - * - * @param initialContext The initial context for the store, either a function - * that returns context based on input, or the context itself - * @param transitions The transitions object defining how the context updates - * due to events - * @returns An actor logic creator function that creates store actor logic - */ -export function fromStore< - TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap, - TInput ->( - initialContext: ((input: TInput) => TContext) | TContext, - transitions: TransitionsFromEventPayloadMap< - TEventPayloadMap, - NoInfer, - EventObject - > -): StoreLogic< - TContext, - ExtractEventsFromPayloadMap, - TInput, - EventObject ->; - /** * An actor logic creator which creates store [actor * logic](https://stately.ai/docs/actors#actor-logic) for use with XState. @@ -53,7 +25,7 @@ export function fromStore< * @param config.context The initial context for the store, either a function * that returns context based on input, or the context itself * @param config.on An object defining the transitions for different event types - * @param config.types Optional object to define custom event types + * @param config.emits Optional object to define emitted event handlers * @returns An actor logic creator function that creates store actor logic */ export function fromStore< @@ -67,7 +39,7 @@ export function fromStore< [K in keyof TEventPayloadMap & string]: StoreAssigner< NoInfer, { type: K } & TEventPayloadMap[K], - ExtractEventsFromPayloadMap + ExtractEvents >; }; emits?: { @@ -77,57 +49,17 @@ export function fromStore< }; }): StoreLogic< TContext, - ExtractEventsFromPayloadMap, - TInput, - ExtractEventsFromPayloadMap ->; -export function fromStore< - TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap, + ExtractEvents, TInput, - TTypes extends { emitted?: EventObject } ->( - initialContextOrObj: - | ((input: TInput) => TContext) - | TContext - | ({ - context: ((input: TInput) => TContext) | TContext; - on: { - [K in keyof TEventPayloadMap & string]: StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - >; - }; - } & { types?: TTypes }), - transitions?: TransitionsFromEventPayloadMap< - TEventPayloadMap, - NoInfer, - EventObject - > -): StoreLogic< - TContext, - ExtractEventsFromPayloadMap, - TInput, - TTypes['emitted'] extends EventObject ? TTypes['emitted'] : EventObject + ExtractEvents > { - let initialContext: ((input: TInput) => TContext) | TContext; - let transitionsObj: TransitionsFromEventPayloadMap< + const initialContext: ((input: TInput) => TContext) | TContext = + config.context; + const transitionsObj: TransitionsFromEventPayloadMap< TEventPayloadMap, NoInfer, EventObject - >; - - if ( - typeof initialContextOrObj === 'object' && - 'context' in initialContextOrObj - ) { - initialContext = initialContextOrObj.context; - transitionsObj = initialContextOrObj.on; - } else { - initialContext = initialContextOrObj; - transitionsObj = transitions!; - } + > = config.on; const transition = createStoreTransition(transitionsObj); return { @@ -138,7 +70,7 @@ export function fromStore< if (typeof effect === 'function') { effect(); } else { - actorScope.emit(effect); + actorScope.emit(effect as ExtractEvents); } } diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 5af036f905..e805c5f1fe 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -2,7 +2,7 @@ import { EnqueueObject, EventObject, EventPayloadMap, - ExtractEventsFromPayloadMap, + ExtractEvents, InteropSubscribable, Observer, Recipe, @@ -65,8 +65,8 @@ function createStoreCore< context: NoInfer, recipe: (context: NoInfer) => void ) => NoInfer -): Store, TEmitted> { - type StoreEvent = ExtractEventsFromPayloadMap; +): Store, TEmitted> { + type StoreEvent = ExtractEvents; let observers: Set>> | undefined; let listeners: Map> | undefined; const initialSnapshot: StoreSnapshot = { @@ -238,7 +238,7 @@ type CreateStoreParameterTypes< [K in keyof TEventPayloadMap & string]: StoreAssigner< NoInfer, { type: K } & TEventPayloadMap[K], - ExtractEventsFromPayloadMap + ExtractEvents >; }; } @@ -248,11 +248,7 @@ type CreateStoreReturnType< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, TEmitted extends EventPayloadMap -> = Store< - TContext, - ExtractEventsFromPayloadMap, - ExtractEventsFromPayloadMap ->; +> = Store, ExtractEvents>; /** * Creates a **store** that has its own internal state and can be sent events @@ -358,14 +354,14 @@ export function createStoreWithProducer< [K in keyof TEventPayloadMap & string]: ( context: NoInfer, event: { type: K } & TEventPayloadMap[K], - enqueue: EnqueueObject> + enqueue: EnqueueObject> ) => void; }; } ): Store< TContext, - ExtractEventsFromPayloadMap, - ExtractEventsFromPayloadMap + ExtractEvents, + ExtractEvents > { return createStoreCore(config.context, config.on, producer); } @@ -403,9 +399,9 @@ export function createStoreTransition< ) { return ( snapshot: StoreSnapshot, - event: ExtractEventsFromPayloadMap + event: ExtractEvents ): [StoreSnapshot, StoreEffect[]] => { - type StoreEvent = ExtractEventsFromPayloadMap; + type StoreEvent = ExtractEvents; let currentContext = snapshot.context; const assigner = transitions?.[event.type as StoreEvent['type']]; const effects: StoreEffect[] = []; diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index bde8ae7d84..edff42361e 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -1,6 +1,6 @@ export type EventPayloadMap = Record; -export type ExtractEventsFromPayloadMap = Values<{ +export type ExtractEvents = Values<{ [K in keyof T & string]: T[K] & { type: K }; }>; diff --git a/packages/xstate-store/test/fromStore.test.ts b/packages/xstate-store/test/fromStore.test.ts index edf41f4f41..d4c6dcdf47 100644 --- a/packages/xstate-store/test/fromStore.test.ts +++ b/packages/xstate-store/test/fromStore.test.ts @@ -2,25 +2,6 @@ import { createActor } from 'xstate'; import { fromStore } from '../src/index.ts'; describe('fromStore', () => { - it('creates an actor from store logic with input (2 args)', () => { - const storeLogic = fromStore((count: number) => ({ count }), { - inc: (ctx, ev: { by: number }) => ({ - ...ctx, - count: ctx.count + ev.by - }) - }); - - const actor = createActor(storeLogic, { - input: 42 - }); - - actor.start(); - - actor.send({ type: 'inc', by: 8 }); - - expect(actor.getSnapshot().context.count).toEqual(50); - }); - it('creates an actor from store logic with input (object API)', () => { const storeLogic = fromStore({ context: (count: number) => ({ count }), From e6c2523a1231a8f2ab9290e532a802a38849519a Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 8 Feb 2025 10:18:49 -0500 Subject: [PATCH 23/25] Fix tests --- packages/xstate-store/test/UseActor.vue | 8 ++++---- packages/xstate-store/test/UseActorRef.vue | 8 ++++---- packages/xstate-store/test/react.test.tsx | 16 ++++++++-------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/xstate-store/test/UseActor.vue b/packages/xstate-store/test/UseActor.vue index 676cd2e75e..0f16cf76d6 100644 --- a/packages/xstate-store/test/UseActor.vue +++ b/packages/xstate-store/test/UseActor.vue @@ -15,14 +15,14 @@ export default defineComponent({ emits: ['rerender'], setup() { const { snapshot, send } = useActor( - fromStore( - { + fromStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ count: ctx.count + 1 }) } - ) + }) ); snapshot.value.context.count satisfies number; diff --git a/packages/xstate-store/test/UseActorRef.vue b/packages/xstate-store/test/UseActorRef.vue index f8548a0ff6..80db6198b4 100644 --- a/packages/xstate-store/test/UseActorRef.vue +++ b/packages/xstate-store/test/UseActorRef.vue @@ -15,14 +15,14 @@ export default defineComponent({ emits: ['rerender'], setup() { const actorRef = useActorRef( - fromStore( - { + fromStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ count: ctx.count + 1 }) } - ) + }) ); const count = useSelector(actorRef, (s) => s.context.count); diff --git a/packages/xstate-store/test/react.test.tsx b/packages/xstate-store/test/react.test.tsx index d626412803..ac8545954d 100644 --- a/packages/xstate-store/test/react.test.tsx +++ b/packages/xstate-store/test/react.test.tsx @@ -196,17 +196,17 @@ it('useSelector (@xstate/react) should work', () => { }); it('useActor (@xstate/react) should work', () => { - const store = fromStore( - { + const store = fromStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ ...ctx, count: ctx.count + 1 }) } - ); + }); const Counter = () => { const [snapshot, send] = useActor(store); @@ -235,17 +235,17 @@ it('useActor (@xstate/react) should work', () => { }); it('useActorRef (@xstate/react) should work', () => { - const store = fromStore( - { + const store = fromStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ ...ctx, count: ctx.count + 1 }) } - ); + }); const Counter = () => { const actorRef = useActorRef(store); From 2276832d2087eebf4e55b41f08677c3d26c129e0 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 8 Feb 2025 21:11:48 -0500 Subject: [PATCH 24/25] Update packages/xstate-store/test/fromStore.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mateusz Burzyński --- packages/xstate-store/test/fromStore.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/xstate-store/test/fromStore.test.ts b/packages/xstate-store/test/fromStore.test.ts index d4c6dcdf47..4b8472219c 100644 --- a/packages/xstate-store/test/fromStore.test.ts +++ b/packages/xstate-store/test/fromStore.test.ts @@ -2,7 +2,7 @@ import { createActor } from 'xstate'; import { fromStore } from '../src/index.ts'; describe('fromStore', () => { - it('creates an actor from store logic with input (object API)', () => { + it('creates an actor from store logic with input', () => { const storeLogic = fromStore({ context: (count: number) => ({ count }), on: { From c2ea8e35e23cf1a582824edc48bec29e2d63bf40 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sat, 8 Feb 2025 21:31:03 -0500 Subject: [PATCH 25/25] Improve jsdoc comments and test --- .changeset/spotty-moose-joke.md | 8 +++- .changeset/wise-bikes-leave.md | 5 ++- packages/xstate-store/src/store.ts | 51 ++++++++++++++--------- packages/xstate-store/test/types.test.tsx | 16 ++++--- 4 files changed, 49 insertions(+), 31 deletions(-) diff --git a/.changeset/spotty-moose-joke.md b/.changeset/spotty-moose-joke.md index cf76b09472..1a6fbf15e5 100644 --- a/.changeset/spotty-moose-joke.md +++ b/.changeset/spotty-moose-joke.md @@ -12,7 +12,9 @@ The `createStoreWithProducer(…)` function now only accepts two arguments: a `p // count: 0 // }, // { -// increment: (context) => ({ count: context.count + 1 }) +// increment: (context) => { +// context.count++; +// } // } // ); @@ -22,7 +24,9 @@ createStoreWithProducer(producer, { count: 0 }, on: { - increment: (context) => ({ count: context.count + 1 }) + increment: (context) => { + context.count++; + } } }); ``` diff --git a/.changeset/wise-bikes-leave.md b/.changeset/wise-bikes-leave.md index 4da9c4de0b..0f5b7a9e0b 100644 --- a/.changeset/wise-bikes-leave.md +++ b/.changeset/wise-bikes-leave.md @@ -8,7 +8,10 @@ Emitted event types are now specified in functions on the `emits` property of th const store = createStore({ // … emits: { - increased: (payload: { upBy: number }) => {} + increased: (payload: { upBy: number }) => { + // You can execute a side-effect here + // or leave it empty + } }, on: { inc: (ctx, ev: { by: number }, enq) => { diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index e805c5f1fe..57ff25ca1e 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -36,6 +36,13 @@ function toObserver( }; } +/** + * Updates a context object using a recipe function. + * + * @param context - The current context + * @param recipe - A function that describes how to update the context + * @returns The updated context + */ function setter( context: TContext, recipe: Recipe @@ -260,11 +267,9 @@ type CreateStoreReturnType< * const store = createStore({ * context: { count: 0 }, * on: { - * inc: (context, event: { by: number }) => { - * return { - * count: context.count + event.by - * }; - * } + * inc: (context, event: { by: number }) => ({ + * count: context.count + event.by + * }) * } * }); * @@ -275,6 +280,12 @@ type CreateStoreReturnType< * store.send({ type: 'inc', by: 5 }); * // Logs { context: { count: 5 }, status: 'active', ... } * ``` + * + * @param config - The store configuration object + * @param config.context - The initial state of the store + * @param config.on - An object mapping event types to transition functions + * @returns A store instance with methods to send events and subscribe to state + * changes */ function _createStore< TContext extends StoreContext, @@ -320,14 +331,10 @@ export const createStore: { * import { produce } from 'immer'; * * const store = createStoreWithProducer(produce, { - * // Initial context - * { count: 0 }, - * // Transitions - * { - * on: { - * inc: (context, event: { by: number }) => { - * context.count += event.by; - * } + * context: { count: 0 }, + * on: { + * inc: (context, event: { by: number }) => { + * context.count += event.by; * } * } * }); @@ -373,12 +380,14 @@ declare global { } /** - * Creates a store function, which is a function that accepts the current - * snapshot and an event and returns a new snapshot. + * Creates a store transition function that handles state updates based on + * events. * - * @param transitions - * @param producer - * @returns + * @param transitions - An object mapping event types to transition functions + * @param producer - Optional producer function (e.g., Immer's produce) for + * immutable updates + * @returns A transition function that takes a snapshot and event and returns a + * new snapshot with effects */ export function createStoreTransition< TContext extends StoreContext, @@ -466,7 +475,11 @@ export function createStoreTransition< }; } -// create a unique 6-char id +/** + * Generates a unique 6-character identifier. + * + * @returns A random string identifier + */ function uniqueId() { return Math.random().toString(36).slice(6); } diff --git a/packages/xstate-store/test/types.test.tsx b/packages/xstate-store/test/types.test.tsx index 1e4a0c7281..a927977249 100644 --- a/packages/xstate-store/test/types.test.tsx +++ b/packages/xstate-store/test/types.test.tsx @@ -71,19 +71,17 @@ describe('emitted', () => { }); }); - it("can can't subscribe to a unknown event", () => { - const store = createStore< - {}, - {}, - { - increased: { upBy: number }; - decreased: { downBy: number }; - } - >({ + it("can't subscribe to a unknown event", () => { + const store = createStore({ + emits: { + increased: (_: { upBy: number }) => {} + }, context: {}, on: {} }); + store.on('increased', (ev) => {}); + store.on( // @ts-expect-error 'unknown',