From 56a5717fc0a20b19e4276c166f480a93b2dde540 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Mon, 7 Jul 2025 08:32:47 +0900 Subject: [PATCH 1/5] Refactor store logic to improve context updates and snapshot handling - Removed unused setter function and associated recipe parameter. - Updated transition logic to prevent unnecessary updates when the snapshot remains unchanged. - Enhanced tests to verify behavior of effect-only and emit-only transitions, ensuring no updates trigger when the snapshot is the same. --- packages/xstate-store/src/store.ts | 79 +++++++----------------- packages/xstate-store/test/store.test.ts | 76 +++++++++++++++++++++++ 2 files changed, 100 insertions(+), 55 deletions(-) diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index c47a02d527..8a29191418 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -7,7 +7,6 @@ import { ExtractEvents, InteropSubscribable, Observer, - Recipe, Store, StoreAssigner, StoreContext, @@ -28,20 +27,6 @@ const symbolObservable: typeof Symbol.observable = (() => (typeof Symbol === 'function' && Symbol.observable) || '@@observable')() as any; -/** - * 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 -): TContext { - return recipe(context); -} - const inspectionObservers = new WeakMap< Store, Set> @@ -76,20 +61,25 @@ function createStoreCore< const transition = logic.transition; function receive(event: StoreEvent) { - let effects: StoreEffect[]; - [currentSnapshot, effects] = transition(currentSnapshot, event); + const [nextSnapshot, effects] = transition(currentSnapshot, event); + + if (nextSnapshot === currentSnapshot && !effects.length) { + return; + } + + currentSnapshot = nextSnapshot; inspectionObservers.get(store)?.forEach((observer) => { observer.next?.({ type: '@xstate.snapshot', event, - snapshot: currentSnapshot, + snapshot: nextSnapshot, actorRef: store, rootId: store.sessionId }); }); - atom.set(currentSnapshot); + atom.set(nextSnapshot); for (const effect of effects) { if (typeof effect === 'function') { @@ -423,7 +413,7 @@ export function createStoreTransition< event: ExtractEvents ): [StoreSnapshot, StoreEffect[]] => { type StoreEvent = ExtractEvents; - let currentContext = snapshot.context; + const currentContext = snapshot.context; const assigner = transitions?.[event.type as StoreEvent['type']]; const effects: StoreEffect[] = []; @@ -447,43 +437,22 @@ export function createStoreTransition< return [snapshot, effects]; } - if (typeof assigner === 'function') { - currentContext = producer - ? producer(currentContext, (draftContext) => - (assigner as StoreProducerAssigner)( - draftContext, - event, - enqueue - ) + const nextContext = producer + ? producer(currentContext, (draftContext) => + (assigner as StoreProducerAssigner)( + draftContext, + event, + enqueue ) - : setter(currentContext, (draftContext) => - Object.assign( - {}, - currentContext, - assigner?.( - draftContext, - event as any, // TODO: help me - enqueue - ) - ) - ); - } else { - const partialUpdate: Record = {}; - for (const key of Object.keys(assigner)) { - const propAssignment = assigner[key]; - partialUpdate[key] = - typeof propAssignment === 'function' - ? (propAssignment as StoreAssigner)( - currentContext, - event, - enqueue - ) - : propAssignment; - } - currentContext = Object.assign({}, currentContext, partialUpdate); - } + ) + : (assigner(currentContext, event as any, enqueue) ?? currentContext); - return [{ ...snapshot, context: currentContext }, effects]; + return [ + nextContext === currentContext + ? snapshot + : { ...snapshot, context: nextContext }, + effects + ]; }; } diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index 0039dc37c0..5de99731cf 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -447,6 +447,43 @@ it('effects can be enqueued', async () => { expect(store.getSnapshot().context.count).toEqual(0); }); +it('effect-only transitions should execute effects', () => { + const spy = vi.fn(); + const store = createStore({ + context: { count: 0 }, + on: { + justEffect: (ctx, _, enq) => { + enq.effect(spy); + } + } + }); + + store.trigger.justEffect(); + + expect(spy).toHaveBeenCalledTimes(1); +}); + +it('emits-only transitions should emit events', () => { + const spy = vi.fn(); + const store = createStore({ + context: { count: 0 }, + emits: { + emitted: () => {} + }, + on: { + justEmit: (ctx, _, enq) => { + enq.emit.emitted(); + } + } + }); + + store.on('emitted', spy); + + store.trigger.justEmit(); + + expect(spy).toHaveBeenCalledTimes(1); +}); + describe('store.trigger', () => { it('should allow triggering events with a fluent API', () => { const store = createStore({ @@ -766,6 +803,45 @@ it('can be created with a logic object', () => { store.getSnapshot().context.count satisfies string; }); +it('should not trigger update if the snapshot is the same', () => { + const store = createStore({ + context: { count: 0 }, + on: { + doNothing: (ctx) => ctx + } + }); + + const spy = vi.fn(); + store.subscribe(spy); + + store.trigger.doNothing(); + store.trigger.doNothing(); + + expect(spy).toHaveBeenCalledTimes(0); +}); + +it('should not trigger update if the snapshot is the same even if there are effects', () => { + const store = createStore({ + context: { count: 0 }, + on: { + doNothing: (ctx, _, enq) => { + enq.effect(() => { + // … + }); + return ctx; + } + } + }); + + const spy = vi.fn(); + store.subscribe(spy); + + store.trigger.doNothing(); + store.trigger.doNothing(); + + expect(spy).toHaveBeenCalledTimes(0); +}); + describe('types', () => { it('AnyStoreConfig', () => { function transformStoreConfig(_config: AnyStoreConfig): void {} From f10f659bd60d116db22bf3e0153f854f7b53c9a3 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Mon, 7 Jul 2025 08:35:43 +0900 Subject: [PATCH 2/5] Changeset --- .changeset/proud-bananas-do.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .changeset/proud-bananas-do.md diff --git a/.changeset/proud-bananas-do.md b/.changeset/proud-bananas-do.md new file mode 100644 index 0000000000..a719a87afa --- /dev/null +++ b/.changeset/proud-bananas-do.md @@ -0,0 +1,30 @@ +--- +'@xstate/store': minor +--- + +Added support for effect-only transitions that don't trigger state updates. Now, when a transition returns the same state but includes effects, subscribers won't be notified of a state change, but the effects will still be executed. This helps prevent unnecessary re-renders while maintaining side effect functionality. + +```ts +it('should not trigger update if the snapshot is the same even if there are effects', () => { + const store = createStore({ + context: { count: 0 }, + on: { + doNothing: (ctx, _, enq) => { + enq.effect(() => { + // … + }); + return ctx; // Context is the same, so no update is triggered + // This is the same as not returning anything (void) + } + } + }); + + const spy = vi.fn(); + store.subscribe(spy); + + store.trigger.doNothing(); + store.trigger.doNothing(); + + expect(spy).toHaveBeenCalledTimes(0); +}); +``` From 5eda50b381d016e637bb8fcba8b923dd99f43e90 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Mon, 7 Jul 2025 08:38:33 +0900 Subject: [PATCH 3/5] Remove unused type --- packages/xstate-store/src/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index ee16929c94..c76fc4de62 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -6,8 +6,6 @@ export type ExtractEvents = Values<{ [K in keyof T & string]: T[K] & { type: K }; }>; -export type Recipe = (state: T) => TReturn; - type AllKeys = T extends any ? keyof T : never; type EmitterFunction = ( From ff0385b2301ee4bdd5e48cf3c43f903891660943 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 13 Jul 2025 06:28:16 +0700 Subject: [PATCH 4/5] Keep inspection consistent --- packages/xstate-store/src/store.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 8a29191418..ad69b79cc0 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -63,12 +63,6 @@ function createStoreCore< function receive(event: StoreEvent) { const [nextSnapshot, effects] = transition(currentSnapshot, event); - if (nextSnapshot === currentSnapshot && !effects.length) { - return; - } - - currentSnapshot = nextSnapshot; - inspectionObservers.get(store)?.forEach((observer) => { observer.next?.({ type: '@xstate.snapshot', @@ -79,6 +73,12 @@ function createStoreCore< }); }); + if (nextSnapshot === currentSnapshot && !effects.length) { + return; + } + + currentSnapshot = nextSnapshot; + atom.set(nextSnapshot); for (const effect of effects) { From 1bdeb0f0191c81ca2083e39e96510732b998068b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 8 Oct 2025 22:23:35 +0200 Subject: [PATCH 5/5] smplify --- packages/xstate-store/src/store.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 0b55f25351..1836b14e77 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -63,6 +63,7 @@ function createStoreCore< function receive(event: StoreEvent) { const [nextSnapshot, effects] = transition(currentSnapshot, event); + currentSnapshot = nextSnapshot; inspectionObservers.get(store)?.forEach((observer) => { observer.next?.({ @@ -74,12 +75,6 @@ function createStoreCore< }); }); - if (nextSnapshot === currentSnapshot && !effects.length) { - return; - } - - currentSnapshot = nextSnapshot; - atom.set(nextSnapshot); for (const effect of effects) {