From 05670ceb430d7c2316f37421b656c23c21e6dde4 Mon Sep 17 00:00:00 2001 From: Magnus Dalin Date: Sun, 15 Jun 2025 22:37:44 +0200 Subject: [PATCH 1/4] Add actor select function simliar to @xstate/store --- .changeset/tough-brooms-grin.md | 5 + packages/core/src/createActor.ts | 23 +++ packages/core/src/types.ts | 6 + packages/core/test/select.test.ts | 275 ++++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+) create mode 100644 .changeset/tough-brooms-grin.md create mode 100644 packages/core/test/select.test.ts diff --git a/.changeset/tough-brooms-grin.md b/.changeset/tough-brooms-grin.md new file mode 100644 index 0000000000..6ae4d29790 --- /dev/null +++ b/.changeset/tough-brooms-grin.md @@ -0,0 +1,5 @@ +--- +'xstate': minor +--- + +Add actor select function simliar to @xstate/store diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index 30e1268b3a..140822e57d 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -33,6 +33,7 @@ import type { EventFromLogic, InputFrom, IsNotNever, + Readable, Snapshot, SnapshotFrom } from './types.ts'; @@ -482,6 +483,28 @@ export class Actor }; } + public select>( + selector: (snapshot: TSnapshot) => TSelected, + equalityFn: (a: TSelected, b: TSelected) => boolean = Object.is + ): Readable { + return { + subscribe: (observerOrFn) => { + const observer = toObserver(observerOrFn); + const snapshot: TSnapshot = this.getSnapshot(); + let previousSelected = selector(snapshot); + + return this.subscribe((snapshot) => { + const nextSelected = selector(snapshot); + if (!equalityFn(previousSelected, nextSelected)) { + previousSelected = nextSelected; + observer.next?.(nextSelected); + } + }); + }, + get: () => selector(this.getSnapshot()) + }; + } + /** Starts the Actor from the initial state */ public start(): this { if (this._processingStatus === ProcessingStatus.Running) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 733f92574e..ef9f1c6baa 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1911,6 +1911,12 @@ export interface Subscription { unsubscribe(): void; } +export type Selection = Readable; + +export interface Readable extends Subscribable { + get: () => T; +} + export interface InteropObservable { [Symbol.observable]: () => InteropSubscribable; } diff --git a/packages/core/test/select.test.ts b/packages/core/test/select.test.ts new file mode 100644 index 0000000000..a51901ae5b --- /dev/null +++ b/packages/core/test/select.test.ts @@ -0,0 +1,275 @@ +import { assign, SnapshotFrom } from '../src'; +import { createMachine } from '../src/index.ts'; +import { createActor } from '../src/index.ts'; + +describe('select', () => { + it('should get current value', () => { + const machine = createMachine({ + types: {} as { context: { data: number } }, + context: { data: 42 }, + initial: 'G', + states: { + G: { + on: { + INC: { + actions: assign({ data: ({ context }) => context.data + 1 }) + } + } + } + } + }); + + const service = createActor(machine).start(); + const selection = service.select(({ context }) => context.data); + + expect(selection.get()).toBe(42); + + service.send({ type: 'INC' }); + + expect(selection.get()).toBe(43); + }); + + it('should subscribe to changes', () => { + const machine = createMachine({ + types: {} as { context: { data: number } }, + context: { data: 42 }, + initial: 'G', + states: { + G: { + on: { + INC: { + actions: assign({ data: ({ context }) => context.data + 1 }) + } + } + } + } + }); + + const callback = vi.fn(); + const service = createActor(machine).start(); + const selection = service.select(({ context }) => context.data); + selection.subscribe(callback); + + service.send({ type: 'INC' }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(43); + }); + + it('should not notify if selected value has not changed', () => { + const machine = createMachine({ + types: {} as { context: { data: number; other: string } }, + context: { data: 42, other: 'foo' }, + initial: 'G', + states: { + G: { + on: { + INC: { + actions: assign({ data: ({ context }) => context.data + 1 }) + } + } + } + } + }); + + const callback = vi.fn(); + const service = createActor(machine).start(); + const selection = service.select(({ context }) => context.other); + selection.subscribe(callback); + + service.send({ type: 'INC' }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should support custom equality function', () => { + const machine = createMachine({ + types: {} as { + context: { age: number; name: string }; + events: + | { + type: 'UPDATE_NAME'; + name: string; + } + | { + type: 'UPDATE_AGE'; + age: number; + }; + }, + context: { age: 42, name: 'John' }, + initial: 'G', + states: { + G: { + on: { + UPDATE_NAME: { + actions: assign({ name: ({ event }) => event.name }) + }, + UPDATE_AGE: { + actions: assign({ age: ({ event }) => event.age }) + } + } + } + } + }); + + const service = createActor(machine).start(); + + const callback = vi.fn(); + const selector = ({ context }: SnapshotFrom) => ({ + name: context.name, + age: context.age + }); + const equalityFn = (a: { name: string }, b: { name: string }) => + a.name === b.name; // Only compare names + + service.select(selector, equalityFn).subscribe(callback); + + service.send({ type: 'UPDATE_AGE', age: 66 }); + expect(callback).not.toHaveBeenCalled(); + + service.send({ type: 'UPDATE_NAME', name: 'Jane' }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should unsubscribe correctly', () => { + const machine = createMachine({ + types: {} as { context: { data: number } }, + context: { data: 42 }, + initial: 'G', + states: { + G: { + on: { + INC: { + actions: assign({ data: ({ context }) => context.data + 1 }) + } + } + } + } + }); + + const service = createActor(machine).start(); + + const callback = vi.fn(); + const selection = service.select(({ context }) => context.data); + const subscription = selection.subscribe(callback); + + subscription.unsubscribe(); + service.send({ type: 'INC' }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should handle updates with multiple subscribers', () => { + interface PositionContext { + position: { + x: number; + y: number; + }; + } + + const machine = createMachine({ + types: {} as { + context: { + user: { age: number; name: string }; + position: { + x: number; + y: number; + }; + }; + events: + | { + type: 'UPDATE_USER'; + user: { age: number; name: string }; + } + | { + type: 'UPDATE_POSITION'; + position: { + x: number; + y: number; + }; + }; + }, + context: { position: { x: 0, y: 0 }, user: { name: 'John', age: 30 } }, + initial: 'G', + states: { + G: { + on: { + UPDATE_USER: { + actions: assign({ user: ({ event }) => event.user }) + }, + UPDATE_POSITION: { + actions: assign({ position: ({ event }) => event.position }) + } + } + } + } + }); + + const store = createActor(machine).start(); + + // Mock DOM manipulation callback + const renderCallback = vi.fn(); + store + .select(({ context }) => context.position) + .subscribe((position) => { + renderCallback(position); + }); + + // Mock logger callback for x position only + const loggerCallback = vi.fn(); + store + .select(({ context }) => context.position.x) + .subscribe((x) => { + loggerCallback(x); + }); + + // Simulate position update + store.send({ + type: 'UPDATE_POSITION', + position: { x: 100, y: 200 } + }); + + // Verify render callback received full position update + expect(renderCallback).toHaveBeenCalledTimes(1); + expect(renderCallback).toHaveBeenCalledWith({ x: 100, y: 200 }); + + // Verify logger callback received only x position + expect(loggerCallback).toHaveBeenCalledTimes(1); + expect(loggerCallback).toHaveBeenCalledWith(100); + + // Simulate another update + store.send({ + type: 'UPDATE_POSITION', + position: { x: 150, y: 300 } + }); + + expect(renderCallback).toHaveBeenCalledTimes(2); + expect(renderCallback).toHaveBeenLastCalledWith({ x: 150, y: 300 }); + expect(loggerCallback).toHaveBeenCalledTimes(2); + expect(loggerCallback).toHaveBeenLastCalledWith(150); + + // Simulate changing only the y position + store.send({ + type: 'UPDATE_POSITION', + position: { x: 150, y: 400 } + }); + + expect(renderCallback).toHaveBeenCalledTimes(3); + expect(renderCallback).toHaveBeenLastCalledWith({ x: 150, y: 400 }); + + // loggerCallback should not have been called + expect(loggerCallback).toHaveBeenCalledTimes(2); + + // Simulate changing only the user + store.send({ + type: 'UPDATE_USER', + user: { name: 'Jane', age: 25 } + }); + + // renderCallback should not have been called + expect(renderCallback).toHaveBeenCalledTimes(3); + + // loggerCallback should not have been called + expect(loggerCallback).toHaveBeenCalledTimes(2); + }); +}); From 76f8bd87a84550e37521ccfb83f0b9c0927914bc Mon Sep 17 00:00:00 2001 From: Magnus Dalin Date: Sun, 15 Jun 2025 22:49:59 +0200 Subject: [PATCH 2/4] Add select function to the ActorRef type --- packages/core/src/types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ef9f1c6baa..112ffa46ac 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1996,6 +1996,10 @@ export interface ActorRef< emitted: TEmitted & (TType extends '*' ? unknown : { type: TType }) ) => void ) => Subscription; + select( + selector: (snapshot: TSnapshot) => TSelected, + equalityFn?: (a: TSelected, b: TSelected) => boolean + ): Readable; } export type AnyActorRef = ActorRef< From da482f7a12c951e276c654136e21c566975d39d7 Mon Sep 17 00:00:00 2001 From: Magnus Dalin Date: Sun, 22 Jun 2025 22:31:32 +0200 Subject: [PATCH 3/4] chore: remove unused generic types --- packages/core/src/types.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 112ffa46ac..3a90931707 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1911,8 +1911,6 @@ export interface Subscription { unsubscribe(): void; } -export type Selection = Readable; - export interface Readable extends Subscribable { get: () => T; } @@ -1996,7 +1994,7 @@ export interface ActorRef< emitted: TEmitted & (TType extends '*' ? unknown : { type: TType }) ) => void ) => Subscription; - select( + select( selector: (snapshot: TSnapshot) => TSelected, equalityFn?: (a: TSelected, b: TSelected) => boolean ): Readable; From e1f2808cf2f94be2e1cde64783d390a313bf042b Mon Sep 17 00:00:00 2001 From: Magnus Dalin Date: Sun, 22 Jun 2025 22:51:53 +0200 Subject: [PATCH 4/4] restore generic type for snapshot to avoid typecheck error --- packages/core/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3a90931707..f9dfd5f0af 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1994,7 +1994,7 @@ export interface ActorRef< emitted: TEmitted & (TType extends '*' ? unknown : { type: TType }) ) => void ) => Subscription; - select( + select( selector: (snapshot: TSnapshot) => TSelected, equalityFn?: (a: TSelected, b: TSelected) => boolean ): Readable;