diff --git a/.changeset/five-walls-approve.md b/.changeset/five-walls-approve.md new file mode 100644 index 0000000000..9193469d5c --- /dev/null +++ b/.changeset/five-walls-approve.md @@ -0,0 +1,27 @@ +--- +'@xstate/store': major +--- + +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 new file mode 100644 index 0000000000..c3233a0b2d --- /dev/null +++ b/.changeset/great-candles-rule.md @@ -0,0 +1,24 @@ +--- +'@xstate/store': major +--- + +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/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/.changeset/spotty-moose-joke.md b/.changeset/spotty-moose-joke.md new file mode 100644 index 0000000000..cf76b09472 --- /dev/null +++ b/.changeset/spotty-moose-joke.md @@ -0,0 +1,28 @@ +--- +'@xstate/store': major +--- + +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 new file mode 100644 index 0000000000..ea9cd7668e --- /dev/null +++ b/.changeset/thick-paws-invite.md @@ -0,0 +1,19 @@ +--- +'@xstate/store': major +--- + +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 }) + } +}) +``` 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 5469e4b9ea..2497f3094f 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< @@ -61,29 +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 - > - | StorePropertyAssigner< - 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 TEmitted & string]: ( + payload: { type: K } & TEmitted[K] + ) => void; + }; +}): StoreLogic< TContext, ExtractEventsFromPayloadMap, TInput, - TTypes['emitted'] extends EventObject ? TTypes['emitted'] : EventObject + ExtractEventsFromPayloadMap >; export function fromStore< TContext extends StoreContext, @@ -97,17 +93,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< @@ -142,9 +132,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; }, diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 6a651d58be..bd4a262c8e 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -1,5 +1,4 @@ import { - Cast, EnqueueObject, EventObject, EventPayloadMap, @@ -9,11 +8,10 @@ import { Recipe, Store, StoreAssigner, - StoreCompleteAssigner, StoreContext, + StoreEffect, StoreInspectionEvent, - StorePartialAssigner, - StorePropertyAssigner, + StoreProducerAssigner, StoreSnapshot } from './types'; @@ -57,21 +55,15 @@ 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?: ( + producer?: ( context: NoInfer, - recipe: (context: NoInfer) => NoInfer + recipe: (context: NoInfer) => void ) => NoInfer ): Store, TEmitted> { type StoreEvent = ExtractEventsFromPayloadMap; @@ -96,11 +88,11 @@ function createStoreCore< } }; - const transition = createStoreTransition(transitions, updater); + 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?.({ @@ -114,7 +106,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 = { @@ -196,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; } @@ -207,23 +220,45 @@ 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 + >; }; +type CreateStoreParameterTypes< + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TEmitted extends EventPayloadMap +> = [ + definition: { + context: TContext; + emits?: { + [K in keyof TEmitted & string]: (payload: TEmitted[K]) => void; + }; + on: { + [K in keyof TEventPayloadMap & string]: StoreAssigner< + NoInfer, + { type: K } & TEventPayloadMap[K], + ExtractEventsFromPayloadMap + >; + }; + } +]; + +type CreateStoreReturnType< + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TEmitted extends EventPayloadMap +> = Store< + TContext, + ExtractEventsFromPayloadMap, + ExtractEventsFromPayloadMap +>; + /** * Creates a **store** that has its own internal state and can be sent events * that update its internal state based on transitions. @@ -253,85 +288,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, - types -}: { - context: TContext; - on: { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - > - | StorePropertyAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - >; - }; -} & { types?: TTypes }): 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. - * - * @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 + TEmitted extends EventPayloadMap >( - initialContext: TContext, - transitions: TransitionsFromEventPayloadMap< - TEventPayloadMap, + ...[{ context, on }]: CreateStoreParameterTypes< TContext, - EventObject + TEventPayloadMap, + TEmitted > -): Store, EventObject>; - -export function createStore(initialContextOrObject: any, transitions?: any) { - if (transitions === undefined) { - return createStoreCore( - initialContextOrObject.context, - initialContextOrObject.on - ); - } - return createStoreCore(initialContextOrObject, transitions); +): 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, + TEmitted extends EventPayloadMap + >( + ...args: CreateStoreParameterTypes + ): CreateStoreReturnType; + < + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TEmitted extends EventPayloadMap + >( + ...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. @@ -365,7 +355,7 @@ export function createStore(initialContextOrObject: any, transitions?: any) { 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 @@ -376,53 +366,16 @@ export function createStoreWithProducer< [K in keyof TEventPayloadMap & string]: ( context: NoInfer, event: { type: K } & TEventPayloadMap[K], - enqueue: EnqueueObject + enqueue: EnqueueObject> ) => 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); +): Store< + TContext, + ExtractEventsFromPayloadMap, + ExtractEventsFromPayloadMap +> { + return createStoreCore(config.context, config.on, producer); } declare global { @@ -436,7 +389,7 @@ declare global { * snapshot and an event and returns a new snapshot. * * @param transitions - * @param updater + * @param producer * @returns */ export function createStoreTransition< @@ -445,44 +398,54 @@ 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?: ( + producer?: ( context: TContext, - recipe: (context: TContext) => TContext + recipe: (context: TContext) => void ) => TContext ) { 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 enqueue = { - emit: (ev: TEmitted) => { - emitted.push(ev); + const effects: StoreEffect[] = []; + + const enqueue: EnqueueObject = { + emit: new Proxy({} as any, { + get: (_, eventType: string) => { + return (payload: any) => { + effects.push({ + type: eventType, + ...payload + }); + }; + } + }), + effect: (fn) => { + effects.push(fn); } }; if (!assigner) { - return [snapshot, emitted]; + return [snapshot, effects]; } if (typeof assigner === 'function') { - currentContext = updater - ? updater(currentContext, (draftContext) => - ( - assigner as StoreCompleteAssigner - )?.(draftContext, event, enqueue) + currentContext = producer + ? producer(currentContext, (draftContext) => + (assigner as StoreProducerAssigner)( + draftContext, + event, + enqueue + ) ) : setter(currentContext, (draftContext) => Object.assign( @@ -501,20 +464,17 @@ 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); } - 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 23b90758b8..b1baacee4f 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -6,10 +6,15 @@ export type ExtractEventsFromPayloadMap = Values<{ export type Recipe = (state: T) => TReturn; -export type EnqueueObject = { - emit: (ev: TEmitted) => void; +export type EnqueueObject = { + emit: { + [E in TEmittedEvent as E['type']]: (payload: Omit) => void; + }; + effect: (fn: () => void) => void; }; +export type StoreEffect = (() => void) | TEmitted; + export type StoreAssigner< TContext extends StoreContext, TEvent extends EventObject, @@ -18,31 +23,13 @@ export type StoreAssigner< context: TContext, event: TEvent, enq: EnqueueObject -) => Partial; -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 = | { @@ -105,8 +92,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: { + [E in TEvent as E['type'] & string]: IsEmptyObject< + Omit + > extends true + ? () => Omit + : (eventPayload: Omit) => void; + }; } +export type IsEmptyObject = T extends Record ? true : false; + export type AnyStore = Store; export type Compute = { [K in keyof A]: A[K] }; @@ -300,3 +310,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 = { + [E in TEvent as E['type']]: E; +}; 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/fromStore.test.ts b/packages/xstate-store/test/fromStore.test.ts index 0f6cc1d75d..f89c1be159 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, { @@ -26,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 + }; } } }); @@ -49,16 +54,17 @@ 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: { - 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.increased({ upBy: ev.by }); + return { + ...ctx, + count: ctx.count + ev.by + }; } } }); diff --git a/packages/xstate-store/test/react.test.tsx b/packages/xstate-store/test/react.test.tsx index b05dcba39f..d626412803 100644 --- a/packages/xstate-store/test/react.test.tsx +++ b/packages/xstate-store/test/react.test.tsx @@ -9,16 +9,17 @@ import { import ReactDOM from 'react-dom'; it('useSelector should work', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } - ); + }); const Counter = () => { const count = useSelector(store, (s) => s.context.count); @@ -47,19 +48,21 @@ it('useSelector should work', () => { }); it('useSelector can take in a custom comparator', () => { - const store = createStore( - { + const store = createStore({ + context: { items: [1, 2] }, - { - same: { - items: () => [1, 2] // different array, same items - }, - different: { - items: () => [3, 4] - } + on: { + same: (ctx) => ({ + ...ctx, + items: [1, 2] // different array, same items + }), + different: (ctx) => ({ + ...ctx, + items: [3, 4] + }) } - ); + }); let renderCount = 0; const Items = () => { @@ -112,16 +115,17 @@ it('useSelector can take in a custom comparator', () => { }); it('can batch updates', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } - ); + }); const Counter = () => { const count = useSelector(store, (s) => s.context.count); @@ -153,16 +157,17 @@ it('can batch updates', () => { }); it('useSelector (@xstate/react) should work', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } - ); + }); const Counter = () => { const count = useXStateSelector(store, (s) => s.context.count); @@ -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..1d8698cfae 100644 --- a/packages/xstate-store/test/solid.test.tsx +++ b/packages/xstate-store/test/solid.test.tsx @@ -19,13 +19,19 @@ const useRenderTracker = (...accessors: Accessor[]) => { /** A commonly reused store for testing selector behaviours. */ const createCounterStore = () => - createStore( - { count: 0, other: 0 }, - { - increment: { count: ({ count }) => count + 1 }, - other: { other: ({ other }) => other + 1 } + createStore({ + context: { count: 0, other: 0 }, + on: { + increment: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }), + other: (ctx) => ({ + ...ctx, + other: ctx.other + 1 + }) } - ); + }); describe('Solid.js integration', () => { describe('useSelector', () => { @@ -72,13 +78,19 @@ describe('Solid.js integration', () => { const INITIAL_ITEMS_STRING = INITIAL_ITEMS.join(','); const DIFFERENT_ITEMS_STRING = DIFFERENT_ITEMS.join(','); - const store = createStore( - { items: INITIAL_ITEMS }, - { - same: { items: () => [...INITIAL_ITEMS] }, - different: { items: () => DIFFERENT_ITEMS } + const store = createStore({ + context: { items: INITIAL_ITEMS }, + on: { + same: (ctx) => ({ + ...ctx, + items: [...INITIAL_ITEMS] + }), + different: (ctx) => ({ + ...ctx, + 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 2e5db1bd07..c13ecefc10 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -1,14 +1,17 @@ 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', () => { 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,18 +27,19 @@ 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' }, - { - inc: { - count: (ctx) => ctx.count + 1 - }, - updateBoth: { - count: () => 42, + const store = createStore({ + context: { count: 0, greeting: 'hello' }, + on: { + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }), + updateBoth: (ctx) => ({ + count: 42, greeting: 'hi' - } + }) } - ); + }); store.send({ type: 'inc' @@ -49,14 +53,14 @@ it('can update context with a property assigner', () => { }); it('handles unknown events (does not do anything)', () => { - const store = createStore( - { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + const store = createStore({ + context: { count: 0 }, + on: { + inc: (ctx) => ({ + count: ctx.count + 1 + }) } - ); + }); store.send({ // @ts-expect-error @@ -66,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 @@ -87,7 +91,7 @@ it('updates state from sent events', () => { }; } } - ); + }); store.send({ type: 'inc', by: 9 }); store.send({ type: 'dec', by: 3 }); @@ -99,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({ @@ -144,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 }; }); @@ -165,7 +167,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; } } @@ -175,16 +177,16 @@ it('createStoreWithProducer(…) infers the context type properly with a produce }); it('can be observed', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + count: ctx.count + 1 + }) } - ); + }); const counts: number[] = []; @@ -206,16 +208,16 @@ it('can be observed', () => { }); it('can be inspected', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + count: ctx.count + 1 + }) } - ); + }); const evs: any[] = []; @@ -257,18 +259,15 @@ 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 } - }, 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 +287,15 @@ 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 } - }, context: { count: 0 }, + emits: { + increased: (_: { upBy: number }) => {} + }, on: { inc: (ctx, _, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); + enq.emit.increased({ upBy: 1 }); return { ...ctx, @@ -322,15 +319,15 @@ 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 } - }, context: { count: 0 }, + emits: { + increased: (_: { upBy: number }) => {} + }, on: { inc: (ctx, _, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); + enq.emit.increased({ upBy: 1 }); return { ...ctx, @@ -350,3 +347,101 @@ 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); +}); + +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 + }); + }); +}); diff --git a/packages/xstate-store/test/types.test.tsx b/packages/xstate-store/test/types.test.tsx index 2bbf40e24a..1e4a0c7281 100644 --- a/packages/xstate-store/test/types.test.tsx +++ b/packages/xstate-store/test/types.test.tsx @@ -3,18 +3,14 @@ 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 } - }, context: {}, + emits: { + increased: (_: { upBy: number }) => {} + }, on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); - return ctx; - } + inc: (ctx, _, enq) => { + enq.emit.increased({ upBy: 1 }); + return ctx; } } }); @@ -22,72 +18,50 @@ describe('emitted', () => { it("can't emit an unknown event", () => { createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } - }, context: {}, - on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ - // @ts-expect-error - type: 'unknown' - }); - return ctx; - } - } - } - }); - }); - - it("can't emit a known event with wrong payload", () => { - createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } + emits: { + increased: (_: { upBy: number }) => {}, + decreased: (_: { downBy: number }) => {} }, - context: {}, on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ - type: 'increased', - // @ts-expect-error - upBy: 'bazinga' - }); - return ctx; - } + inc: (ctx, _, enq) => { + enq.emit + // @ts-expect-error + .unknown(); + return ctx; } } }); }); - it('can emit an event when emitted events are unknown', () => { + it("can't emit a known event with wrong payload", () => { createStore({ context: {}, + emits: { + increased: (_: { upBy: number }) => {}, + decreased: (_: { downBy: number }) => {} + }, on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ - type: 'unknown' - }); - return ctx; - } + inc: (ctx, _, enq) => { + enq.emit.increased({ + // @ts-expect-error + upBy: 'bazinga' + }); + return ctx; } } }); }); 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: {} }); @@ -98,12 +72,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: {} });