From 48df3b36a1f91b210696ace15a3edff24ed3f9d8 Mon Sep 17 00:00:00 2001 From: expelledboy <102334+expelledboy@users.noreply.github.com> Date: Sun, 2 Feb 2025 16:33:03 +0700 Subject: [PATCH 1/5] Add getter api to @xstate/store --- packages/xstate-store/src/store.ts | 166 +++++++++++++++-------- packages/xstate-store/src/types.ts | 76 ++++++----- packages/xstate-store/test/store.test.ts | 114 ++++++++++++++++ 3 files changed, 270 insertions(+), 86 deletions(-) diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 5ae50c7003..f635453505 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -5,6 +5,7 @@ import { ExtractEvents, 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 = (() => @@ -58,31 +61,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, TEmitted, TGetters> { type StoreEvent = ExtractEvents; - 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) { @@ -98,8 +100,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?.({ @@ -122,7 +129,7 @@ function createStoreCore< } } - const store: Store = { + const store: Store = { on(emittedEventType, handler) { if (!listeners) { listeners = new Map(); @@ -171,7 +178,9 @@ function createStoreCore< } }; }, - [symbolObservable](): InteropSubscribable> { + [symbolObservable](): InteropSubscribable< + StoreSnapshot + > { return this; }, inspect: (observerOrFn) => { @@ -202,7 +211,12 @@ function createStoreCore< } }; }, - trigger: new Proxy({} as Store['trigger'], { + trigger: {} as any + }; + + (store as any).trigger = new Proxy( + {} as Store['trigger'], + { get: (_, eventType: string) => { return (payload: any) => { store.send({ @@ -211,8 +225,8 @@ function createStoreCore< }); }; } - }) - }; + } + ); return store; } @@ -234,7 +248,8 @@ export type TransitionsFromEventPayloadMap< type CreateStoreParameterTypes< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventPayloadMap + TEmitted extends EventPayloadMap, + TGetters extends Record = {} > = [ definition: { context: TContext; @@ -248,14 +263,21 @@ type CreateStoreParameterTypes< ExtractEvents >; }; + getters?: StoreGetters; } ]; type CreateStoreReturnType< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventPayloadMap -> = Store, ExtractEvents>; + TEmitted extends EventPayloadMap, + TGetters extends Record = {} +> = Store< + TContext, + ExtractEvents, + ExtractEvents, + TGetters +>; /** * Creates a **store** that has its own internal state and can be sent events @@ -290,15 +312,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: { @@ -308,17 +332,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; /** @@ -350,11 +386,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: { @@ -364,13 +399,15 @@ export function createStoreWithProducer< enqueue: EnqueueObject> ) => void; }; + getters?: StoreGetters; } ): Store< TContext, ExtractEvents, - ExtractEvents + ExtractEvents, + TGetters > { - return createStoreCore(config.context, config.on, producer); + return createStoreCore(config.context, config.on, config.getters, producer); } declare global { @@ -401,17 +438,13 @@ export function createStoreTransition< TEmitted >; }, - producer?: ( - context: TContext, - recipe: (context: TContext) => void - ) => TContext + producer?: Producer ) { return ( - snapshot: StoreSnapshot, + currentContext: TContext, event: ExtractEvents - ): [StoreSnapshot, StoreEffect[]] => { + ): [TContext, StoreEffect[]] => { type StoreEvent = ExtractEvents; - let currentContext = snapshot.context; const assigner = transitions?.[event.type as StoreEvent['type']]; const effects: StoreEffect[] = []; @@ -432,7 +465,7 @@ export function createStoreTransition< }; if (!assigner) { - return [snapshot, effects]; + return [currentContext, effects]; } if (typeof assigner === 'function') { @@ -471,7 +504,7 @@ export function createStoreTransition< currentContext = Object.assign({}, currentContext, partialUpdate); } - return [{ ...snapshot, context: currentContext }, effects]; + return [currentContext, effects]; }; } @@ -483,3 +516,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 edff42361e..727ff2dd7c 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. @@ -119,9 +123,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; /** @@ -312,3 +319,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 5a9597f1a0..ea322b2380 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -530,3 +530,117 @@ it('the emit type is not overridden by the payload', () => { drawer: { id: 'a' } }); }); + +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 }) + ]); + }); +}); From 1b2463badda0beafb83b4eca473581930e987eda Mon Sep 17 00:00:00 2001 From: expelledboy <102334+expelledboy@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:18:26 +0700 Subject: [PATCH 2/5] Fix store utils type signatures --- packages/xstate-store/src/fromStore.ts | 67 ++++++++++++++++---------- packages/xstate-store/src/store.ts | 2 +- packages/xstate-store/src/types.ts | 7 +-- 3 files changed, 45 insertions(+), 31 deletions(-) diff --git a/packages/xstate-store/src/fromStore.ts b/packages/xstate-store/src/fromStore.ts index 551a48335c..9f83f4cfc1 100644 --- a/packages/xstate-store/src/fromStore.ts +++ b/packages/xstate-store/src/fromStore.ts @@ -1,21 +1,28 @@ import { ActorLogic } from 'xstate'; -import { createStoreTransition, TransitionsFromEventPayloadMap } from './store'; +import { createStoreTransition, computeGetters } from './store'; import { EventPayloadMap, StoreContext, - Snapshot, StoreSnapshot, EventObject, ExtractEvents, - StoreAssigner + StoreAssigner, + StoreGetters } from './types'; type StoreLogic< TContext extends StoreContext, TEvent extends EventObject, TInput, - TEmitted extends EventObject -> = ActorLogic, TEvent, TInput, any, TEmitted>; + TEmitted extends EventObject, + TGetters extends Record any> = {} +> = ActorLogic< + StoreSnapshot, + TEvent, + TInput, + any, + TEmitted +>; /** * An actor logic creator which creates store [actor @@ -26,13 +33,15 @@ type StoreLogic< * that returns context based on input, or the context itself * @param config.on An object defining the transitions for different event types * @param config.emits Optional object to define emitted event handlers + * @param config.getters Optional object to define store getters * @returns An actor logic creator function that creates store actor logic */ export function fromStore< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, TInput, - TEmitted extends EventPayloadMap + TEmitted extends EventPayloadMap, + TGetters extends Record any> = {} >(config: { context: ((input: TInput) => TContext) | TContext; on: { @@ -47,47 +56,55 @@ export function fromStore< payload: { type: K } & TEmitted[K] ) => void; }; + getters?: StoreGetters; }): StoreLogic< TContext, ExtractEvents, TInput, - ExtractEvents + ExtractEvents, + TGetters > { - const initialContext: ((input: TInput) => TContext) | TContext = - config.context; - const transitionsObj: TransitionsFromEventPayloadMap< - TEventPayloadMap, - NoInfer, - EventObject - > = config.on; + const initialContext = config.context; + const transitionsObj = config.on; + const getters = config.getters; const transition = createStoreTransition(transitionsObj); + return { transition: (snapshot, event, actorScope) => { - const [nextSnapshot, effects] = transition(snapshot, event); + const [newContext, effects] = transition(snapshot.context, event); + + const newSnapshot = { + ...snapshot, + context: newContext, + ...computeGetters(newContext, getters) + } as StoreSnapshot; for (const effect of effects) { if (typeof effect === 'function') { effect(); } else { - actorScope.emit(effect as ExtractEvents); + actorScope.emit(effect); } } - return nextSnapshot; + return newSnapshot; }, getInitialSnapshot: (_, input: TInput) => { + const context = + typeof initialContext === 'function' + ? initialContext(input) + : initialContext; + return { status: 'active', - context: - typeof initialContext === 'function' - ? initialContext(input) - : initialContext, + context, output: undefined, - error: undefined - } satisfies StoreSnapshot; + error: undefined, + ...computeGetters(context, getters) + } satisfies StoreSnapshot; }, - getPersistedSnapshot: (s: StoreSnapshot) => s, - restoreSnapshot: (s: Snapshot) => s as StoreSnapshot + getPersistedSnapshot: (s) => s, + restoreSnapshot: (s) => s as StoreSnapshot }; } diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index f635453505..22109c05ef 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -517,7 +517,7 @@ function uniqueId() { return Math.random().toString(36).slice(6); } -const computeGetters = < +export const computeGetters = < TContext extends StoreContext, TGetters extends Record any> >( diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index 727ff2dd7c..35d3074b54 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -123,11 +123,8 @@ export type AnyStore = Store; export type Compute = { [K in keyof A]: A[K] }; -export type SnapshotFromStore< - TStore extends Store, - TGetters extends Record any> -> = - TStore extends Store +export type SnapshotFromStore> = + TStore extends Store ? StoreSnapshot : never; From 207afc7f11fe2fd9500321098e48b1447af27ceb Mon Sep 17 00:00:00 2001 From: expelledboy <102334+expelledboy@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:29:59 +0700 Subject: [PATCH 3/5] Add changeset --- .changeset/fresh-keys-enjoy.md | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .changeset/fresh-keys-enjoy.md diff --git a/.changeset/fresh-keys-enjoy.md b/.changeset/fresh-keys-enjoy.md new file mode 100644 index 0000000000..abffad22e6 --- /dev/null +++ b/.changeset/fresh-keys-enjoy.md @@ -0,0 +1,42 @@ +--- +'@xstate/store': minor +--- + +Added store getters API for computed values derived from context: + +```ts +const store = createStore({ + context: { count: 2 }, + getters: { + doubled: (ctx) => ctx.count * 2, + squared: (ctx) => ctx.count ** 2, + // Can depend on other getters (types can not be inferred, due to circular references) + sum: (ctx, getters: { doubled: number; squared: number }) => + getters.doubled + getters.squared + }, + on: { + inc: (ctx) => ({ count: ctx.count + 1 }) + } +}); + +// Getters are available on store snapshots +var snapshot = store.getSnapshot(); +assert.equal(snapshot.doubled, 4); +assert.equal(snapshot.squared, 4); +assert.equal(snapshot.sum, 8); + +// Automatically update when context changes +store.send({ type: 'inc' }); +var snapshot = store.getSnapshot(); +assert.equal(snapshot.doubled, 6); +assert.equal(snapshot.squared, 36); +assert.equal(snapshot.sum, 42); +``` + +Key features: + +- Getters recalculate automatically when context changes +- Included in inspection snapshots +- Can depend on other getters via proxy +- Works with Immer producer API +- Full type safety for computed values From 817aaf2c9356d59e2721f5bd737944f7bec0fe82 Mon Sep 17 00:00:00 2001 From: expelledboy <102334+expelledboy@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:33:43 +0700 Subject: [PATCH 4/5] Fix unused type export TransitionsFromEventPayloadMap --- packages/xstate-store/src/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 22109c05ef..7348f1e367 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -231,7 +231,7 @@ function createStoreCore< return store; } -export type TransitionsFromEventPayloadMap< +type TransitionsFromEventPayloadMap< TEventPayloadMap extends EventPayloadMap, TContext extends StoreContext, TEmitted extends EventObject From 02fa5ce495b9d583935424b43218065f1b5dfaac Mon Sep 17 00:00:00 2001 From: expelledboy <102334+expelledboy@users.noreply.github.com> Date: Fri, 14 Feb 2025 20:29:37 +0700 Subject: [PATCH 5/5] Move getters into nested prop in store snapshot --- .changeset/fresh-keys-enjoy.md | 16 +++++----- packages/xstate-store/src/fromStore.ts | 4 +-- packages/xstate-store/src/store.ts | 4 +-- packages/xstate-store/src/types.ts | 3 +- packages/xstate-store/test/store.test.ts | 39 +++++++++++++++--------- 5 files changed, 38 insertions(+), 28 deletions(-) diff --git a/.changeset/fresh-keys-enjoy.md b/.changeset/fresh-keys-enjoy.md index abffad22e6..8ad95684f1 100644 --- a/.changeset/fresh-keys-enjoy.md +++ b/.changeset/fresh-keys-enjoy.md @@ -20,17 +20,17 @@ const store = createStore({ }); // Getters are available on store snapshots -var snapshot = store.getSnapshot(); -assert.equal(snapshot.doubled, 4); -assert.equal(snapshot.squared, 4); -assert.equal(snapshot.sum, 8); +var getters = store.getSnapshot().getters; +assert.equal(getters.doubled, 4); +assert.equal(getters.squared, 4); +assert.equal(getters.sum, 8); // Automatically update when context changes store.send({ type: 'inc' }); -var snapshot = store.getSnapshot(); -assert.equal(snapshot.doubled, 6); -assert.equal(snapshot.squared, 36); -assert.equal(snapshot.sum, 42); +var getters = store.getSnapshot().getters; +assert.equal(getters.doubled, 6); +assert.equal(getters.squared, 36); +assert.equal(getters.sum, 42); ``` Key features: diff --git a/packages/xstate-store/src/fromStore.ts b/packages/xstate-store/src/fromStore.ts index 9f83f4cfc1..ad48972a3f 100644 --- a/packages/xstate-store/src/fromStore.ts +++ b/packages/xstate-store/src/fromStore.ts @@ -77,7 +77,7 @@ export function fromStore< const newSnapshot = { ...snapshot, context: newContext, - ...computeGetters(newContext, getters) + getters: computeGetters(newContext, getters) } as StoreSnapshot; for (const effect of effects) { @@ -101,7 +101,7 @@ export function fromStore< context, output: undefined, error: undefined, - ...computeGetters(context, getters) + getters: computeGetters(context, getters) } satisfies StoreSnapshot; }, getPersistedSnapshot: (s) => s, diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 7348f1e367..675ecf0946 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -82,7 +82,7 @@ function createStoreCore< status: 'active', output: undefined, error: undefined, - ...computeGetters(initialContext, getters) + getters: computeGetters(initialContext, getters) }; let currentSnapshot: StoreSnapshot = initialSnapshot; @@ -105,7 +105,7 @@ function createStoreCore< currentSnapshot = { ...currentSnapshot, context: newContext, - ...computeGetters(newContext, getters) + getters: computeGetters(newContext, getters) } as StoreSnapshot; inspectionObservers.get(store)?.forEach((observer) => { diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index 35d3074b54..83fc2ad54b 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -58,7 +58,8 @@ export type StoreSnapshot< TGetters extends Record any> > = Snapshot & { context: TContext; -} & ResolvedGetters; + getters: StoreGetters; +}; /** * An actor-like object that: diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index ea322b2380..7b0c3843d5 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -544,12 +544,12 @@ describe('getters', () => { } }); - expect(store.getSnapshot().doubled).toBe(4); - expect(store.getSnapshot().squared).toBe(4); + expect(store.getSnapshot().getters.doubled).toBe(4); + expect(store.getSnapshot().getters.squared).toBe(4); store.send({ type: 'inc' }); - expect(store.getSnapshot().doubled).toBe(6); - expect(store.getSnapshot().squared).toBe(9); + expect(store.getSnapshot().getters.doubled).toBe(6); + expect(store.getSnapshot().getters.squared).toBe(9); }); it('handles getter dependencies', () => { @@ -567,10 +567,10 @@ describe('getters', () => { } }); - expect(store.getSnapshot().total).toBeCloseTo(22); // 20 + 2 = 22 + expect(store.getSnapshot().getters.total).toBeCloseTo(22); // 20 + 2 = 22 store.send({ type: 'updatePrice', value: 20 }); - expect(store.getSnapshot().total).toBeCloseTo(44); // 40 + 4 = 44 + expect(store.getSnapshot().getters.total).toBeCloseTo(44); // 40 + 4 = 44 }); it('updates getters when context changes', () => { @@ -587,10 +587,10 @@ describe('getters', () => { } }); - expect(store.getSnapshot().hasItems).toBe(false); + expect(store.getSnapshot().getters.hasItems).toBe(false); store.send({ type: 'addItem', item: 'test' }); - expect(store.getSnapshot().hasItems).toBe(true); + expect(store.getSnapshot().getters.hasItems).toBe(true); }); it('works with immer producer', () => { @@ -608,12 +608,12 @@ describe('getters', () => { } }); - expect(store.getSnapshot().sum).toBe(3); - expect(store.getSnapshot().product).toBe(2); + expect(store.getSnapshot().getters.sum).toBe(3); + expect(store.getSnapshot().getters.product).toBe(2); store.send({ type: 'update', a: 3 }); - expect(store.getSnapshot().sum).toBe(5); - expect(store.getSnapshot().product).toBe(6); + expect(store.getSnapshot().getters.sum).toBe(5); + expect(store.getSnapshot().getters.product).toBe(6); }); it('includes getters in inspection snapshots', () => { @@ -638,9 +638,18 @@ describe('getters', () => { 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 }) + expect.objectContaining({ + context: { value: 5 }, + getters: { squared: 25 } + }), + expect.objectContaining({ + context: { value: 6 }, + getters: { squared: 36 } + }), + expect.objectContaining({ + context: { value: 7 }, + getters: { squared: 49 } + }) ]); }); });