diff --git a/.changeset/tasty-ravens-prove.md b/.changeset/tasty-ravens-prove.md new file mode 100644 index 0000000000..c3af9cdc6c --- /dev/null +++ b/.changeset/tasty-ravens-prove.md @@ -0,0 +1,16 @@ +--- +'@xstate/store': minor +--- + +The store now extends EventTarget, allowing for native DOM event handling capabilities while maintaining the existing `.on()` API. This change: + +- Adds support for standard `.addEventListener(…)` and `.removeEventListener(…)` methods +- Simplifies internal event handling by leveraging native `EventTarget` functionality +- Maintains full backwards compatibility with existing `.on(…)` method + +```ts +// ... +store.addEventListener('incremented', (event) => { + console.log(event.detail); +}); +``` diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 6a651d58be..9bed65207f 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -74,130 +74,7 @@ function createStoreCore< recipe: (context: NoInfer) => NoInfer ) => NoInfer ): Store, TEmitted> { - type StoreEvent = ExtractEventsFromPayloadMap; - let observers: Set>> | undefined; - let listeners: Map> | undefined; - const initialSnapshot: StoreSnapshot = { - context: initialContext, - status: 'active', - output: undefined, - error: undefined - }; - let currentSnapshot: StoreSnapshot = initialSnapshot; - - const emit = (ev: TEmitted) => { - if (!listeners) { - return; - } - const type = ev.type; - const typeListeners = listeners.get(type); - if (typeListeners) { - typeListeners.forEach((listener) => listener(ev)); - } - }; - - const transition = createStoreTransition(transitions, updater); - - function receive(event: StoreEvent) { - let emitted: TEmitted[]; - [currentSnapshot, emitted] = transition(currentSnapshot, event); - - inspectionObservers.get(store)?.forEach((observer) => { - observer.next?.({ - type: '@xstate.snapshot', - event, - snapshot: currentSnapshot, - actorRef: store, - rootId: store.sessionId - }); - }); - - observers?.forEach((o) => o.next?.(currentSnapshot)); - - emitted.forEach(emit); - } - - const store: Store = { - on(emittedEventType, handler) { - if (!listeners) { - listeners = new Map(); - } - let eventListeners = listeners.get(emittedEventType); - if (!eventListeners) { - eventListeners = new Set(); - listeners.set(emittedEventType, eventListeners); - } - const wrappedHandler = handler.bind(undefined); - eventListeners.add(wrappedHandler); - - return { - unsubscribe() { - eventListeners.delete(wrappedHandler); - } - }; - }, - sessionId: uniqueId(), - send(event) { - inspectionObservers.get(store)?.forEach((observer) => { - observer.next?.({ - type: '@xstate.event', - event, - sourceRef: undefined, - actorRef: store, - rootId: store.sessionId - }); - }); - receive(event as unknown as StoreEvent); - }, - getSnapshot() { - return currentSnapshot; - }, - getInitialSnapshot() { - return initialSnapshot; - }, - subscribe(observerOrFn) { - const observer = toObserver(observerOrFn); - observers ??= new Set(); - observers.add(observer); - - return { - unsubscribe() { - return observers?.delete(observer); - } - }; - }, - [symbolObservable](): InteropSubscribable> { - return this; - }, - inspect: (observerOrFn) => { - const observer = toObserver(observerOrFn); - inspectionObservers.set( - store, - inspectionObservers.get(store) ?? new Set() - ); - inspectionObservers.get(store)!.add(observer); - - observer.next?.({ - type: '@xstate.actor', - actorRef: store, - rootId: store.sessionId - }); - - observer.next?.({ - type: '@xstate.snapshot', - snapshot: initialSnapshot, - event: { type: '@xstate.init' }, - actorRef: store, - rootId: store.sessionId - }); - - return { - unsubscribe() { - return inspectionObservers.get(store)?.delete(observer); - } - }; - } - }; + const store = new StoreImpl(initialContext, transitions, updater); return store; } @@ -224,6 +101,155 @@ export type TransitionsFromEventPayloadMap< >; }; +class StoreImpl< + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TEmitted extends EventObject + > + extends EventTarget + implements + Store, TEmitted> +{ + public transition: ( + snapshot: StoreSnapshot, + event: ExtractEventsFromPayloadMap + ) => [StoreSnapshot, TEmitted[]]; + constructor( + initialContext: TContext, + transitions: TransitionsFromEventPayloadMap< + TEventPayloadMap, + TContext, + TEmitted + >, + updater?: ( + context: TContext, + recipe: (context: TContext) => TContext + ) => TContext + ) { + super(); + this.transition = createStoreTransition(transitions, updater); + this.initialSnapshot = { + context: initialContext, + status: 'active', + output: undefined, + error: undefined + }; + this.currentSnapshot = this.initialSnapshot; + this.observers = new Set(); + this._emit = this._emit.bind(this); + } + private listeners: Map> | undefined; + public sessionId: string = uniqueId(); + public on( + emittedEventType: TEmittedType, + handler: (event: Extract) => void + ) { + const wrappedHandler = ((e: CustomEvent) => { + handler(e.detail as Extract); + }) as EventListener; + + this.addEventListener(emittedEventType, wrappedHandler); + + return { + unsubscribe: () => { + this.removeEventListener(emittedEventType, wrappedHandler); + } + }; + } + + public initialSnapshot: StoreSnapshot; + public currentSnapshot: StoreSnapshot; + public observers: Set>>; + public send(event: ExtractEventsFromPayloadMap) { + inspectionObservers.get(this)?.forEach((observer) => { + observer.next?.({ + type: '@xstate.event', + event, + sourceRef: undefined, + actorRef: this, + rootId: this.sessionId + }); + }); + this.receive( + event as unknown as ExtractEventsFromPayloadMap + ); + } + private _emit(ev: TEmitted) { + super.dispatchEvent(new CustomEvent(ev.type, { detail: ev })); + } + public receive(event: ExtractEventsFromPayloadMap) { + let emitted: TEmitted[]; + [this.currentSnapshot, emitted] = this.transition( + this.currentSnapshot, + event + ); + + inspectionObservers.get(this)?.forEach((observer) => { + observer.next?.({ + type: '@xstate.snapshot', + event, + snapshot: this.currentSnapshot, + actorRef: this, + rootId: this.sessionId + }); + }); + + this.observers.forEach((o) => o.next?.(this.currentSnapshot)); + emitted.forEach(this._emit); + } + public getSnapshot() { + return this.currentSnapshot; + } + public getInitialSnapshot() { + return this.initialSnapshot; + } + public subscribe( + observerOrFn: + | Observer> + | ((snapshot: StoreSnapshot) => void) + ) { + const observer = toObserver(observerOrFn); + this.observers.add(observer); + return { + unsubscribe: () => { + this.observers.delete(observer); + } + }; + } + [symbolObservable](): InteropSubscribable> { + return this; + } + public inspect( + observerOrFn: + | Observer + | ((event: StoreInspectionEvent) => void) + ) { + const observer = toObserver(observerOrFn); + inspectionObservers.set(this, inspectionObservers.get(this) ?? new Set()); + inspectionObservers.get(this)!.add(observer); + + observer.next?.({ + type: '@xstate.actor', + actorRef: this, + rootId: this.sessionId + }); + + observer.next?.({ + type: '@xstate.snapshot', + snapshot: this.initialSnapshot, + event: { type: '@xstate.init' }, + actorRef: this, + rootId: this.sessionId + }); + + return { + unsubscribe: () => { + return inspectionObservers.get(this)?.delete(observer); + } + }; + } +} + /** * Creates a **store** that has its own internal state and can be sent events * that update its internal state based on transitions. diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index 23b90758b8..bbfb0babb7 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -82,7 +82,8 @@ export interface Store< TEvent extends EventObject, TEmitted extends EventObject > extends Subscribable>, - InteropObservable> { + InteropObservable>, + EventTarget { send: (event: TEvent) => void; getSnapshot: () => StoreSnapshot; getInitialSnapshot: () => StoreSnapshot; diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index 2e5db1bd07..86bb4f1192 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -350,3 +350,178 @@ it('emitted events occur after the snapshot is updated', () => { store.send({ type: 'inc' }); }); + +it('should allow listening to emitted events via addEventListener', () => { + const store = createStore({ + context: { count: 0 }, + types: { + emitted: {} as { type: 'incremented'; value: number } + }, + on: { + increment: (context, _event, { emit }) => { + emit({ type: 'incremented', value: context.count + 1 }); + return { count: context.count + 1 }; + } + } + }); + + const listener = jest.fn(); + store.addEventListener('incremented', ((e: CustomEvent) => { + listener(e.detail); + }) as EventListener); + + store.send({ type: 'increment' }); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'incremented', + value: 1 + }) + ); +}); + +it('should allow removing event listeners', () => { + const store = createStore({ + context: { count: 0 }, + types: { + emitted: {} as { type: 'incremented'; value: number } + }, + on: { + increment: (context, _event, { emit }) => { + emit({ type: 'incremented', value: context.count + 1 }); + return { count: context.count + 1 }; + } + } + }); + + const listener = jest.fn(); + const handler = ((e: CustomEvent) => { + listener(e.detail); + }) as EventListener; + + store.addEventListener('incremented', handler); + store.removeEventListener('incremented', handler); + + store.send({ type: 'increment' }); + + expect(listener).not.toHaveBeenCalled(); +}); + +it('should support both addEventListener and .on() simultaneously', () => { + const store = createStore({ + context: { count: 0 }, + types: { + emitted: {} as { type: 'incremented'; value: number } + }, + on: { + increment: (context, _event, { emit }) => { + emit({ type: 'incremented', value: context.count + 1 }); + return { count: context.count + 1 }; + } + } + }); + + const domListener = jest.fn(); + const onListener = jest.fn(); + + store.addEventListener('incremented', ((e: CustomEvent) => { + domListener(e.detail); + }) as EventListener); + + store.on('incremented', (event) => { + onListener(event); + }); + + store.send({ type: 'increment' }); + + expect(domListener).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'incremented', + value: 1 + }) + ); + expect(onListener).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'incremented', + value: 1 + }) + ); +}); + +it('should dispatch events with correct CustomEvent detail', () => { + const store = createStore({ + context: { count: 0 }, + types: { + emitted: {} as { + type: 'incremented'; + value: number; + metadata: { timestamp: number }; + } + }, + on: { + increment: (context, _event, { emit }) => { + emit({ + type: 'incremented', + value: context.count + 1, + metadata: { timestamp: 123 } + }); + return { count: context.count + 1 }; + } + } + }); + + const listener = jest.fn(); + store.addEventListener('incremented', ((e: CustomEvent) => { + listener(e.type, e.detail); + }) as EventListener); + + store.send({ type: 'increment' }); + + expect(listener).toHaveBeenCalledWith('incremented', { + type: 'incremented', + value: 1, + metadata: { timestamp: 123 } + }); +}); + +it('should handle multiple event listeners for the same event type', () => { + const store = createStore({ + context: { count: 0 }, + types: { + emitted: {} as { type: 'incremented'; value: number } + }, + on: { + increment: (context, _event, { emit }) => { + emit({ type: 'incremented', value: context.count + 1 }); + return { count: context.count + 1 }; + } + } + }); + + const listener1 = jest.fn(); + const listener2 = jest.fn(); + + store.addEventListener('incremented', ((e: CustomEvent) => { + listener1(e.detail); + }) as EventListener); + store.addEventListener('incremented', ((e: CustomEvent) => { + listener2(e.detail); + }) as EventListener); + + store.send({ type: 'increment' }); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + expect(listener1).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'incremented', + value: 1 + }) + ); + expect(listener2).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'incremented', + value: 1 + }) + ); +});