Skip to content

Commit d54cc47

Browse files
authored
[@xstate/store] Add wildcard support for store.on('*', ...) (#5467)
* Add wildcard support for store.on() * Address comments * Nicer type
1 parent dbfe25d commit d54cc47

5 files changed

Lines changed: 138 additions & 6 deletions

File tree

.changeset/bright-walls-sniff.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@xstate/store': minor
3+
---
4+
5+
Add wildcard `'*'` support for `store.on('*', …)` to listen to all emitted events. The handler receives the union of all emitted event types.
6+
7+
```ts
8+
const store = createStore({
9+
context: { count: 0 },
10+
emits: {
11+
increased: (_: { upBy: number }) => {},
12+
decreased: (_: { downBy: number }) => {}
13+
},
14+
on: {
15+
inc: (ctx, _, enq) => {
16+
enq.emit.increased({ upBy: 1 });
17+
return { ...ctx, count: ctx.count + 1 };
18+
}
19+
}
20+
});
21+
22+
store.on('*', (ev) => {
23+
// ev:
24+
// | { type: 'increased'; upBy: number }
25+
// | { type: 'decreased'; downBy: number }
26+
});
27+
```

packages/xstate-store/src/store.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ function createStoreCore<
5757
if (typeListeners) {
5858
typeListeners.forEach((listener) => listener(ev));
5959
}
60+
const wildcardListeners = listeners.get('*' as TEmitted['type']);
61+
if (wildcardListeners) {
62+
wildcardListeners.forEach((listener) => listener(ev));
63+
}
6064
};
6165

6266
const transition = logic.transition;
@@ -270,6 +274,7 @@ export function createStore(
270274
if ('transition' in definitionOrLogic) {
271275
return createStoreCore(definitionOrLogic);
272276
}
277+
273278
const transition = createStoreTransition(definitionOrLogic.on);
274279
const logic: AnyStoreLogic = {
275280
getInitialSnapshot: () => ({

packages/xstate-store/src/types.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,12 @@ export interface Store<
9797
| ((inspectionEvent: StoreInspectionEvent) => void)
9898
) => Subscription;
9999
sessionId: string;
100-
on: <TEmittedType extends TEmitted['type']>(
101-
eventType: TEmittedType,
102-
emittedEventHandler: (
103-
emittedEvent: Compute<TEmitted & { type: TEmittedType }>
100+
on: <TType extends TEmitted['type'] | '*'>(
101+
type: TType,
102+
handler: (
103+
emitted: Compute<
104+
TEmitted & (TType extends '*' ? unknown : { type: TType })
105+
>
104106
) => void
105107
) => Subscription;
106108
/**
@@ -212,9 +214,9 @@ export type SpecificStoreConfig<
212214

213215
type IsEmptyObject<T> = T extends Record<string, never> ? true : false;
214216

215-
export type AnyStore = Store<any, any, any>;
217+
type Compute<A> = A extends any ? { [K in keyof A]: A[K] } : never;
216218

217-
type Compute<A> = { [K in keyof A]: A[K] };
219+
export type AnyStore = Store<any, any, any>;
218220

219221
export type SnapshotFromStore<TStore extends Store<any, any, any>> =
220222
TStore extends Store<infer TContext, any, any>

packages/xstate-store/test/store.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,84 @@ it('emits-only transitions should emit events', () => {
486486
expect(spy).toHaveBeenCalledTimes(1);
487487
});
488488

489+
it('wildcard listener receives all emitted events', () => {
490+
const spy = vi.fn();
491+
const store = createStore({
492+
context: { count: 0 },
493+
emits: {
494+
increased: (_: { upBy: number }) => {},
495+
decreased: (_: { downBy: number }) => {}
496+
},
497+
on: {
498+
inc: (ctx, _, enq) => {
499+
enq.emit.increased({ upBy: 1 });
500+
return { ...ctx, count: ctx.count + 1 };
501+
},
502+
dec: (ctx, _, enq) => {
503+
enq.emit.decreased({ downBy: 1 });
504+
return { ...ctx, count: ctx.count - 1 };
505+
}
506+
}
507+
});
508+
509+
store.on('*', spy);
510+
511+
store.trigger.inc();
512+
expect(spy).toHaveBeenCalledWith({ type: 'increased', upBy: 1 });
513+
514+
store.trigger.dec();
515+
expect(spy).toHaveBeenCalledWith({ type: 'decreased', downBy: 1 });
516+
517+
expect(spy).toHaveBeenCalledTimes(2);
518+
});
519+
520+
it('wildcard listener can be unsubscribed', () => {
521+
const spy = vi.fn();
522+
const store = createStore({
523+
context: { count: 0 },
524+
emits: {
525+
increased: (_: { upBy: number }) => {}
526+
},
527+
on: {
528+
inc: (ctx, _, enq) => {
529+
enq.emit.increased({ upBy: 1 });
530+
return { ...ctx, count: ctx.count + 1 };
531+
}
532+
}
533+
});
534+
535+
const sub = store.on('*', spy);
536+
store.trigger.inc();
537+
expect(spy).toHaveBeenCalledTimes(1);
538+
539+
sub.unsubscribe();
540+
store.trigger.inc();
541+
expect(spy).toHaveBeenCalledTimes(1);
542+
});
543+
544+
it('wildcard listener is called after specific listener', () => {
545+
const order: string[] = [];
546+
const store = createStore({
547+
context: { count: 0 },
548+
emits: {
549+
increased: (_: { upBy: number }) => {}
550+
},
551+
on: {
552+
inc: (ctx, _, enq) => {
553+
enq.emit.increased({ upBy: 1 });
554+
return { ...ctx, count: ctx.count + 1 };
555+
}
556+
}
557+
});
558+
559+
store.on('increased', () => order.push('specific'));
560+
store.on('*', () => order.push('wildcard'));
561+
562+
store.trigger.inc();
563+
564+
expect(order).toEqual(['specific', 'wildcard']);
565+
});
566+
489567
it('async effects can be enqueued', async () => {
490568
const store = createStore({
491569
context: {

packages/xstate-store/test/types.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,26 @@ describe('emitted', () => {
8989
);
9090
});
9191

92+
it('wildcard listener receives union of all emitted events', () => {
93+
const store = createStore({
94+
emits: {
95+
increased: (_: { upBy: number }) => {},
96+
decreased: (_: { downBy: number }) => {}
97+
},
98+
context: {},
99+
on: {}
100+
});
101+
102+
store.on('*', (ev) => {
103+
ev satisfies
104+
| { type: 'increased'; upBy: number }
105+
| { type: 'decreased'; downBy: number };
106+
107+
// @ts-expect-error
108+
ev satisfies { type: 'unknown' };
109+
});
110+
});
111+
92112
it('works with a discriminated union event payload', () => {
93113
createStore({
94114
context: {},

0 commit comments

Comments
 (0)