diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index bd4a262c8e..b35e2d7578 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -5,6 +5,7 @@ import { ExtractEventsFromPayloadMap, InteropSubscribable, Observer, + Producer, Recipe, Store, StoreAssigner, @@ -12,7 +13,9 @@ import { StoreEffect, StoreInspectionEvent, StoreProducerAssigner, - StoreSnapshot + StoreSnapshot, + StoreGetters, + ResolvedGetters } from './types'; const symbolObservable: typeof Symbol.observable = (() => @@ -51,31 +54,30 @@ const inspectionObservers = new WeakMap< function createStoreCore< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventObject + TGetters extends Record any>, + TEmitted extends EventObject = EventObject >( initialContext: TContext, - transitions: { - [K in keyof TEventPayloadMap & string]: StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - TEmitted - >; - }, - producer?: ( - context: NoInfer, - recipe: (context: NoInfer) => void - ) => NoInfer -): Store, TEmitted> { + transitions: TransitionsFromEventPayloadMap< + TEventPayloadMap, + TContext, + TEmitted + >, + getters?: TGetters, + producer?: Producer +): Store { type StoreEvent = ExtractEventsFromPayloadMap; - let observers: Set>> | undefined; + let observers: Set>> | undefined; let listeners: Map> | undefined; - const initialSnapshot: StoreSnapshot = { + + const initialSnapshot: StoreSnapshot = { context: initialContext, status: 'active', output: undefined, - error: undefined + error: undefined, + ...computeGetters(initialContext, getters) }; - let currentSnapshot: StoreSnapshot = initialSnapshot; + let currentSnapshot: StoreSnapshot = initialSnapshot; const emit = (ev: TEmitted) => { if (!listeners) { @@ -91,8 +93,13 @@ function createStoreCore< const transition = createStoreTransition(transitions, producer); function receive(event: StoreEvent) { - let effects: StoreEffect[]; - [currentSnapshot, effects] = transition(currentSnapshot, event); + const [newContext, effects] = transition(currentSnapshot.context, event); + + currentSnapshot = { + ...currentSnapshot, + context: newContext, + ...computeGetters(newContext, getters) + } as StoreSnapshot; inspectionObservers.get(store)?.forEach((observer) => { observer.next?.({ @@ -115,7 +122,7 @@ function createStoreCore< } } - const store: Store = { + const store: Store = { on(emittedEventType, handler) { if (!listeners) { listeners = new Map(); @@ -164,7 +171,9 @@ function createStoreCore< } }; }, - [symbolObservable](): InteropSubscribable> { + [symbolObservable](): InteropSubscribable< + StoreSnapshot + > { return this; }, inspect: (observerOrFn) => { @@ -199,7 +208,7 @@ function createStoreCore< }; (store as any).trigger = new Proxy( - {} as Store['trigger'], + {} as Store['trigger'], { get: (_, eventType: string) => { return (payload: any) => { @@ -232,7 +241,8 @@ export type TransitionsFromEventPayloadMap< type CreateStoreParameterTypes< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventPayloadMap + TEmitted extends EventPayloadMap, + TGetters extends Record = {} > = [ definition: { context: TContext; @@ -246,17 +256,20 @@ type CreateStoreParameterTypes< ExtractEventsFromPayloadMap >; }; + getters?: StoreGetters; } ]; type CreateStoreReturnType< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventPayloadMap + TEmitted extends EventPayloadMap, + TGetters extends Record = {} > = Store< TContext, ExtractEventsFromPayloadMap, - ExtractEventsFromPayloadMap + ExtractEventsFromPayloadMap, + TGetters >; /** @@ -291,15 +304,17 @@ type CreateStoreReturnType< function _createStore< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventPayloadMap + TEmitted extends EventPayloadMap, + TGetters extends Record = {} >( - ...[{ context, on }]: CreateStoreParameterTypes< + ...[{ context, on, getters }]: CreateStoreParameterTypes< TContext, TEventPayloadMap, - TEmitted + TEmitted, + TGetters > -): CreateStoreReturnType { - return createStoreCore(context, on); +): CreateStoreReturnType { + return createStoreCore(context, on, getters); } export const createStore: { @@ -309,17 +324,29 @@ export const createStore: { < TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventPayloadMap + TEmitted extends EventPayloadMap, + TGetters extends Record = {} >( - ...args: CreateStoreParameterTypes - ): CreateStoreReturnType; + ...args: CreateStoreParameterTypes< + TContext, + TEventPayloadMap, + TEmitted, + TGetters + > + ): CreateStoreReturnType; < TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventPayloadMap + TEmitted extends EventPayloadMap, + TGetters extends Record = {} >( - ...args: CreateStoreParameterTypes - ): CreateStoreReturnType; + ...args: CreateStoreParameterTypes< + TContext, + TEventPayloadMap, + TEmitted, + TGetters + > + ): CreateStoreReturnType; } = _createStore; /** @@ -355,11 +382,10 @@ export const createStore: { export function createStoreWithProducer< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmittedPayloadMap extends EventPayloadMap + TEmittedPayloadMap extends EventPayloadMap, + TGetters extends Record = {} >( - producer: NoInfer< - (context: TContext, recipe: (context: TContext) => void) => TContext - >, + producer: NoInfer>, config: { context: TContext; on: { @@ -369,13 +395,15 @@ export function createStoreWithProducer< enqueue: EnqueueObject> ) => void; }; + getters?: StoreGetters; } ): Store< TContext, ExtractEventsFromPayloadMap, - ExtractEventsFromPayloadMap + ExtractEventsFromPayloadMap, + TGetters > { - return createStoreCore(config.context, config.on, producer); + return createStoreCore(config.context, config.on, config.getters, producer); } declare global { @@ -404,17 +432,13 @@ export function createStoreTransition< TEmitted >; }, - producer?: ( - context: TContext, - recipe: (context: TContext) => void - ) => TContext + producer?: Producer ) { return ( - snapshot: StoreSnapshot, + currentContext: TContext, event: ExtractEventsFromPayloadMap - ): [StoreSnapshot, StoreEffect[]] => { + ): [TContext, StoreEffect[]] => { type StoreEvent = ExtractEventsFromPayloadMap; - let currentContext = snapshot.context; const assigner = transitions?.[event.type as StoreEvent['type']]; const effects: StoreEffect[] = []; @@ -435,7 +459,7 @@ export function createStoreTransition< }; if (!assigner) { - return [snapshot, effects]; + return [currentContext, effects]; } if (typeof assigner === 'function') { @@ -474,7 +498,7 @@ export function createStoreTransition< currentContext = Object.assign({}, currentContext, partialUpdate); } - return [{ ...snapshot, context: currentContext }, effects]; + return [currentContext, effects]; }; } @@ -482,3 +506,28 @@ export function createStoreTransition< function uniqueId() { return Math.random().toString(36).slice(6); } + +const computeGetters = < + TContext extends StoreContext, + TGetters extends Record any> +>( + context: TContext, + getters?: TGetters +): ResolvedGetters => { + const computed = {} as ResolvedGetters; + + if (!getters) return computed; + + Object.entries(getters).forEach(([key, fn]) => { + computed[key as keyof TGetters] = fn( + context, + new Proxy(computed, { + get(target, prop) { + return target[prop as keyof typeof target]; + } + }) + ); + }); + + return computed; +}; diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index b1baacee4f..17a84e1a2d 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -23,7 +23,7 @@ export type StoreAssigner< context: TContext, event: TEvent, enq: EnqueueObject -) => TContext | void; +) => Partial | void; export type StoreProducerAssigner< TContext extends StoreContext, @@ -32,31 +32,34 @@ export type StoreProducerAssigner< > = (context: TContext, event: TEvent, enq: EnqueueObject) => void; export type Snapshot = - | { - status: 'active'; - output: undefined; - error: undefined; - } - | { - status: 'done'; - output: TOutput; - error: undefined; - } - | { - status: 'error'; - output: undefined; - error: unknown; - } - | { - status: 'stopped'; - output: undefined; - error: undefined; - }; - -export type StoreSnapshot = Snapshot & { - context: TContext; + | { status: 'active'; output: undefined; error: undefined } + | { status: 'done'; output: TOutput; error: undefined } + | { status: 'error'; output: undefined; error: unknown } + | { status: 'stopped'; output: undefined; error: undefined }; + +export type ResolvedGetters< + TGetters extends Record any> +> = { + [K in keyof TGetters]: ReturnType; }; +export type StoreGetters< + TContext, + TGetters extends Record any> +> = { + [K in keyof TGetters]: ( + context: TContext, + getters: ResolvedGetters + ) => ReturnType; +}; + +export type StoreSnapshot< + TContext, + TGetters extends Record any> +> = Snapshot & { + context: TContext; +} & ResolvedGetters; + /** * An actor-like object that: * @@ -67,12 +70,13 @@ export type StoreSnapshot = Snapshot & { export interface Store< TContext, TEvent extends EventObject, - TEmitted extends EventObject -> extends Subscribable>, - InteropObservable> { + TEmitted extends EventObject, + TGetters extends Record any> = {} +> extends Subscribable>, + InteropObservable> { send: (event: TEvent) => void; - getSnapshot: () => StoreSnapshot; - getInitialSnapshot: () => StoreSnapshot; + getSnapshot: () => StoreSnapshot; + getInitialSnapshot: () => StoreSnapshot; /** * Subscribes to [inspection events](https://stately.ai/docs/inspection) from * the store. @@ -121,9 +125,12 @@ export type AnyStore = Store; export type Compute = { [K in keyof A]: A[K] }; -export type SnapshotFromStore> = - TStore extends Store - ? StoreSnapshot +export type SnapshotFromStore< + TStore extends Store, + TGetters extends Record any> +> = + TStore extends Store + ? StoreSnapshot : never; /** @@ -314,3 +321,8 @@ export type Cast = A extends B ? A : B; export type EventMap = { [E in TEvent as E['type']]: E; }; + +export type Producer = ( + context: TContext, + recipe: (context: TContext) => void +) => TContext; diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index c13ecefc10..21d041506c 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', () => { @@ -445,3 +445,117 @@ describe('store.trigger', () => { }); }); }); + +describe('getters', () => { + it('computes values from context', () => { + const store = createStore({ + context: { count: 2 }, + getters: { + doubled: (ctx: { count: number }) => ctx.count * 2, + squared: (ctx: { count: number }) => ctx.count ** 2 + } as const, + on: { + inc: (ctx) => ({ count: ctx.count + 1 }) + } + }); + + expect(store.getSnapshot().doubled).toBe(4); + expect(store.getSnapshot().squared).toBe(4); + + store.send({ type: 'inc' }); + expect(store.getSnapshot().doubled).toBe(6); + expect(store.getSnapshot().squared).toBe(9); + }); + + it('handles getter dependencies', () => { + const store = createStore({ + context: { price: 10, quantity: 2 }, + getters: { + subtotal: (ctx) => ctx.price * ctx.quantity, + tax: (_, getters: { subtotal: number }): number => + getters.subtotal * 0.1, + total: (_, getters: { subtotal: number; tax: number }): number => + getters.subtotal + getters.tax + }, + on: { + updatePrice: (ctx, ev: { value: number }) => ({ price: ev.value }) + } + }); + + expect(store.getSnapshot().total).toBeCloseTo(22); // 20 + 2 = 22 + + store.send({ type: 'updatePrice', value: 20 }); + expect(store.getSnapshot().total).toBeCloseTo(44); // 40 + 4 = 44 + }); + + it('updates getters when context changes', () => { + const store = createStore({ + context: { items: [] as string[] }, + getters: { + count: (ctx) => ctx.items.length, + hasItems: (_, getters: { count: number }): boolean => getters.count > 0 + }, + on: { + addItem: (ctx, ev: { item: string }) => ({ + items: [...ctx.items, ev.item] + }) + } + }); + + expect(store.getSnapshot().hasItems).toBe(false); + + store.send({ type: 'addItem', item: 'test' }); + expect(store.getSnapshot().hasItems).toBe(true); + }); + + it('works with immer producer', () => { + const store = createStoreWithProducer(produce, { + context: { a: 1, b: 2 }, + getters: { + sum: (ctx) => ctx.a + ctx.b, + product: (ctx) => ctx.a * ctx.b + }, + on: { + update: (ctx, ev: { a?: number; b?: number }) => { + if (ev.a !== undefined) ctx.a = ev.a; + if (ev.b !== undefined) ctx.b = ev.b; + } + } + }); + + expect(store.getSnapshot().sum).toBe(3); + expect(store.getSnapshot().product).toBe(2); + + store.send({ type: 'update', a: 3 }); + expect(store.getSnapshot().sum).toBe(5); + expect(store.getSnapshot().product).toBe(6); + }); + + it('includes getters in inspection snapshots', () => { + const store = createStore({ + context: { value: 5 }, + getters: { + squared: (ctx) => ctx.value ** 2 + }, + on: { + increment: (ctx) => ({ value: ctx.value + 1 }) + } + }); + + const snapshots: any[] = []; + store.inspect((ev) => { + if (ev.type === '@xstate.snapshot') { + snapshots.push(ev.snapshot); + } + }); + + store.send({ type: 'increment' }); + store.send({ type: 'increment' }); + + expect(snapshots).toEqual([ + expect.objectContaining({ context: { value: 5 }, squared: 25 }), + expect.objectContaining({ context: { value: 6 }, squared: 36 }), + expect.objectContaining({ context: { value: 7 }, squared: 49 }) + ]); + }); +});