diff --git a/modules/signals/events/spec/dispatcher.spec.ts b/modules/signals/events/spec/dispatcher.spec.ts index 2dce5471cb..6f377d123b 100644 --- a/modules/signals/events/spec/dispatcher.spec.ts +++ b/modules/signals/events/spec/dispatcher.spec.ts @@ -1,8 +1,15 @@ +import { Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { take } from 'rxjs'; import { type } from '@ngrx/signals'; -import { Dispatcher, event, Events } from '../src'; -import { ReducerEvents } from '../src/events-service'; +import { + Dispatcher, + event, + Events, + provideDispatcher, + ReducerEvents, +} from '../src'; +import { EVENTS } from '../src/events-service'; describe('Dispatcher', () => { it('is provided globally', () => { @@ -33,4 +40,127 @@ describe('Dispatcher', () => { { type: 'set', payload: 10, order: 2 }, ]); }); + + describe('hierarchical dispatchers', () => { + function setup() { + const parentInjector = Injector.create({ + providers: [provideDispatcher()], + parent: TestBed.inject(Injector), + }); + const childInjector = Injector.create({ + providers: [provideDispatcher()], + parent: parentInjector, + }); + + const globalDispatcher = TestBed.inject(Dispatcher); + const parentDispatcher = parentInjector.get(Dispatcher); + const childDispatcher = childInjector.get(Dispatcher); + + const globalEvents = TestBed.inject(Events)[EVENTS]; + const parentEvents = parentInjector.get(Events)[EVENTS]; + const childEvents = childInjector.get(Events)[EVENTS]; + + vitest.spyOn(globalDispatcher, 'dispatch'); + vitest.spyOn(parentDispatcher, 'dispatch'); + vitest.spyOn(globalEvents, 'next'); + vitest.spyOn(parentEvents, 'next'); + vitest.spyOn(childEvents, 'next'); + + return { + globalDispatcher, + parentDispatcher, + childDispatcher, + globalEvents, + parentEvents, + childEvents, + }; + } + + it('dispatches an event to the local dispatcher by default', () => { + const { + globalDispatcher, + parentDispatcher, + childDispatcher, + globalEvents, + parentEvents, + childEvents, + } = setup(); + const increment = event('increment'); + + childDispatcher.dispatch(increment()); + + expect(childEvents.next).toHaveBeenCalledWith(increment()); + expect(parentEvents.next).not.toHaveBeenCalled(); + expect(globalEvents.next).not.toHaveBeenCalled(); + expect(parentDispatcher.dispatch).not.toHaveBeenCalled(); + expect(globalDispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches an event to the local dispatcher when scope is self', () => { + const { + globalDispatcher, + parentDispatcher, + childDispatcher, + globalEvents, + parentEvents, + childEvents, + } = setup(); + const increment = event('increment'); + + childDispatcher.dispatch(increment(), { scope: 'self' }); + + expect(childEvents.next).toHaveBeenCalledWith(increment()); + expect(parentEvents.next).not.toHaveBeenCalled(); + expect(globalEvents.next).not.toHaveBeenCalled(); + expect(parentDispatcher.dispatch).not.toHaveBeenCalled(); + expect(globalDispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches an event to the parent dispatcher when scope is parent', () => { + const { + globalDispatcher, + parentDispatcher, + childDispatcher, + globalEvents, + parentEvents, + childEvents, + } = setup(); + const increment = event('increment'); + + childDispatcher.dispatch(increment(), { scope: 'parent' }); + + expect(childEvents.next).not.toHaveBeenCalled(); + expect(parentEvents.next).toHaveBeenCalledWith(increment()); + expect(globalEvents.next).not.toHaveBeenCalled(); + expect(parentDispatcher.dispatch).toHaveBeenCalledWith( + increment(), + undefined + ); + expect(globalDispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches an event to the parent dispatcher when scope is global', () => { + const { + globalDispatcher, + parentDispatcher, + childDispatcher, + globalEvents, + parentEvents, + childEvents, + } = setup(); + const increment = event('increment'); + + childDispatcher.dispatch(increment(), { scope: 'global' }); + + expect(childEvents.next).not.toHaveBeenCalled(); + expect(parentEvents.next).not.toHaveBeenCalled(); + expect(globalEvents.next).toHaveBeenCalledWith(increment()); + expect(parentDispatcher.dispatch).toHaveBeenCalledWith(increment(), { + scope: 'global', + }); + expect(globalDispatcher.dispatch).toHaveBeenCalledWith(increment(), { + scope: 'global', + }); + }); + }); }); diff --git a/modules/signals/events/spec/events-service.spec.ts b/modules/signals/events/spec/events-service.spec.ts index aeab1604a1..24381dfe39 100644 --- a/modules/signals/events/spec/events-service.spec.ts +++ b/modules/signals/events/spec/events-service.spec.ts @@ -1,6 +1,13 @@ +import { Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { type } from '@ngrx/signals'; -import { Dispatcher, event, EventInstance, Events } from '../src'; +import { + Dispatcher, + event, + EventInstance, + Events, + provideDispatcher, +} from '../src'; import { SOURCE_TYPE } from '../src/events-service'; describe('Events', () => { @@ -68,4 +75,44 @@ describe('Events', () => { expect(sourceTypes).toEqual(['foo', 'bar']); }); }); + + it('receives dispatched events from ancestor Events services', () => { + const parentInjector = Injector.create({ + providers: [provideDispatcher()], + parent: TestBed.inject(Injector), + }); + const childInjector = Injector.create({ + providers: [provideDispatcher()], + parent: parentInjector, + }); + + const globalEvents = TestBed.inject(Events); + const parentEvents = parentInjector.get(Events); + const childEvents = childInjector.get(Events); + const childDispatcher = childInjector.get(Dispatcher); + + const foo = event('foo', type()); + + const globalResult: string[] = []; + const parentResult: string[] = []; + const childResult: string[] = []; + + globalEvents.on(foo).subscribe(({ payload }) => globalResult.push(payload)); + parentEvents.on(foo).subscribe(({ payload }) => parentResult.push(payload)); + childEvents.on(foo).subscribe(({ payload }) => childResult.push(payload)); + + childDispatcher.dispatch(foo('self by default')); + childDispatcher.dispatch(foo('explicit self'), { scope: 'self' }); + childDispatcher.dispatch(foo('parent'), { scope: 'parent' }); + childDispatcher.dispatch(foo('global'), { scope: 'global' }); + + expect(globalResult).toEqual(['global']); + expect(parentResult).toEqual(['parent', 'global']); + expect(childResult).toEqual([ + 'self by default', + 'explicit self', + 'parent', + 'global', + ]); + }); }); diff --git a/modules/signals/events/spec/inject-dispatch.spec.ts b/modules/signals/events/spec/inject-dispatch.spec.ts index 094ad32f57..e3aceac6d6 100644 --- a/modules/signals/events/spec/inject-dispatch.spec.ts +++ b/modules/signals/events/spec/inject-dispatch.spec.ts @@ -19,15 +19,108 @@ describe('injectDispatch', () => { vitest.spyOn(dispatcher, 'dispatch'); dispatch.increment(); - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - type: '[Counter Page] increment', - }); + expect(dispatcher.dispatch).toHaveBeenCalledWith( + { + type: '[Counter Page] increment', + payload: undefined, + }, + { scope: 'self' } + ); dispatch.set({ count: 10 }); - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - type: '[Counter Page] set', - payload: { count: 10 }, + expect(dispatcher.dispatch).toHaveBeenCalledWith( + { + type: '[Counter Page] set', + payload: { count: 10 }, + }, + { scope: 'self' } + ); + }); + + it('creates self-dispatching events with a custom scope', () => { + const usersPageEvents = eventGroup({ + source: 'Users Page', + events: { + opened: type(), + queryChanged: type(), + paginationChanged: type<{ currentPage: number; pageSize: number }>(), + }, + }); + const dispatcher = TestBed.inject(Dispatcher); + const dispatch = TestBed.runInInjectionContext(() => + injectDispatch(usersPageEvents) + ); + vitest.spyOn(dispatcher, 'dispatch'); + + dispatch({ scope: 'self' }).opened(); + expect(dispatcher.dispatch).toHaveBeenCalledWith( + { + type: '[Users Page] opened', + payload: undefined, + }, + { scope: 'self' } + ); + + dispatch({ scope: 'parent' }).queryChanged('ngrx'); + expect(dispatcher.dispatch).toHaveBeenCalledWith( + { + type: '[Users Page] queryChanged', + payload: 'ngrx', + }, + { scope: 'parent' } + ); + + dispatch({ scope: 'global' }).paginationChanged({ + currentPage: 10, + pageSize: 100, }); + expect(dispatcher.dispatch).toHaveBeenCalledWith( + { + type: '[Users Page] paginationChanged', + payload: { currentPage: 10, pageSize: 100 }, + }, + { scope: 'global' } + ); + }); + + it('allows defining event names equal to predefined function properties', () => { + const fooEvents = eventGroup({ + source: 'foo', + events: { + name: type(), + toString: type<{ bar: number }>(), + }, + }); + + const dispatcher = TestBed.inject(Dispatcher); + const dispatch = TestBed.runInInjectionContext(() => + injectDispatch(fooEvents) + ); + vitest.spyOn(dispatcher, 'dispatch'); + + dispatch.name(true); + expect(dispatcher.dispatch).toHaveBeenCalledWith( + { type: '[foo] name', payload: true }, + { scope: 'self' } + ); + + dispatch({ scope: 'parent' }).name(false); + expect(dispatcher.dispatch).toHaveBeenCalledWith( + { type: '[foo] name', payload: false }, + { scope: 'parent' } + ); + + dispatch.toString({ bar: 10 }); + expect(dispatcher.dispatch).toHaveBeenCalledWith( + { type: '[foo] toString', payload: { bar: 10 } }, + { scope: 'self' } + ); + + dispatch({ scope: 'global' }).toString({ bar: 100 }); + expect(dispatcher.dispatch).toHaveBeenCalledWith( + { type: '[foo] toString', payload: { bar: 100 } }, + { scope: 'global' } + ); }); it('creates self-dispatching events with a custom injector', () => { @@ -38,7 +131,10 @@ describe('injectDispatch', () => { vitest.spyOn(dispatcher, 'dispatch'); dispatch.increment(); - expect(dispatcher.dispatch).toHaveBeenCalledWith({ type: 'increment' }); + expect(dispatcher.dispatch).toHaveBeenCalledWith( + { type: 'increment', payload: undefined }, + { scope: 'self' } + ); }); it('throws an error when called outside of an injection context', () => { diff --git a/modules/signals/events/spec/with-effects.spec.ts b/modules/signals/events/spec/with-effects.spec.ts index aa7313d4cb..9c77934517 100644 --- a/modules/signals/events/spec/with-effects.spec.ts +++ b/modules/signals/events/spec/with-effects.spec.ts @@ -10,7 +10,15 @@ import { withProps, withState, } from '@ngrx/signals'; -import { Dispatcher, event, EventInstance, Events, withEffects } from '../src'; +import { + Dispatcher, + event, + EventInstance, + Events, + mapToScope, + toScope, + withEffects, +} from '../src'; import { createLocalService } from '../../spec/helpers'; describe('withEffects', () => { @@ -127,6 +135,47 @@ describe('withEffects', () => { ]); }); + it('dispatches an event with provided scope via toScope', () => { + const Store = signalStore( + { providedIn: 'root' }, + withEffects((_, events = inject(Events)) => ({ + $: events.on(event1).pipe(map(() => [event2(), toScope('parent')])), + })) + ); + + const dispatcher = TestBed.inject(Dispatcher); + vitest.spyOn(dispatcher, 'dispatch'); + + TestBed.inject(Store); + + dispatcher.dispatch(event1()); + expect(dispatcher.dispatch).toHaveBeenCalledWith(event2(), { + scope: 'parent', + }); + }); + + it('dispatches an event with provided scope via mapToScope', () => { + const Store = signalStore( + { providedIn: 'root' }, + withEffects((_, events = inject(Events)) => ({ + $: events.on(event1).pipe( + map(() => event3('ngrx')), + mapToScope('global') + ), + })) + ); + + const dispatcher = TestBed.inject(Dispatcher); + vitest.spyOn(dispatcher, 'dispatch'); + + TestBed.inject(Store); + + dispatcher.dispatch(event1()); + expect(dispatcher.dispatch).toHaveBeenCalledWith(event3('ngrx'), { + scope: 'global', + }); + }); + it('unsubscribes from effects when the store is destroyed', () => { let executionCount = 0; diff --git a/modules/signals/events/src/dispatcher.ts b/modules/signals/events/src/dispatcher.ts index 3728d23cff..70353f0683 100644 --- a/modules/signals/events/src/dispatcher.ts +++ b/modules/signals/events/src/dispatcher.ts @@ -1,6 +1,7 @@ -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, Provider } from '@angular/core'; import { queueScheduler } from 'rxjs'; import { EventInstance } from './event-instance'; +import { EventScope, EventScopeConfig } from './event-scope'; import { Events, EVENTS, ReducerEvents } from './events-service'; /** @@ -16,7 +17,7 @@ import { Events, EVENTS, ReducerEvents } from './events-service'; * * const increment = event('[Counter Page] Increment'); * - * \@Component({ \/* ... *\/ }) + * \@Component({ /* ... *\/ }) * class Counter { * readonly #dispatcher = inject(Dispatcher); * @@ -30,9 +31,67 @@ import { Events, EVENTS, ReducerEvents } from './events-service'; export class Dispatcher { protected readonly reducerEvents = inject(ReducerEvents); protected readonly events = inject(Events); + protected readonly parentDispatcher = inject(Dispatcher, { + skipSelf: true, + optional: true, + }); - dispatch(event: EventInstance): void { - this.reducerEvents[EVENTS].next(event); - queueScheduler.schedule(() => this.events[EVENTS].next(event)); + dispatch( + event: EventInstance, + config?: EventScopeConfig + ): void { + if (this.parentDispatcher && hasParentOrGlobalScope(config)) { + this.parentDispatcher.dispatch( + event, + config.scope === 'global' ? config : undefined + ); + } else { + this.reducerEvents[EVENTS].next(event); + queueScheduler.schedule(() => this.events[EVENTS].next(event)); + } } } + +/** + * @experimental + * @description + * + * Provides scoped instances of Dispatcher and Events services. + * Enables event dispatching within a specific component or feature scope. + * + * @usageNotes + * + * ```ts + * import { Dispatcher, event } from '@ngrx/signals/events'; + * + * const increment = event('[Counter Page] Increment'); + * + * \@Component({ + * /* ... *\/ + * providers: [provideDispatcher()], + * }) + * class Counter { + * readonly #dispatcher = inject(Dispatcher); + * + * increment(): void { + * // Dispatching an event to the local Dispatcher. + * this.#dispatcher.dispatch(increment()); + * + * // Dispatching an event to the parent Dispatcher. + * this.#dispatcher.dispatch(increment(), { scope: 'parent' }); + * + * // Dispatching an event to the global Dispatcher. + * this.#dispatcher.dispatch(increment(), { scope: 'global' }); + * } + * } + * ``` + */ +export function provideDispatcher(): Provider[] { + return [Events, ReducerEvents, Dispatcher]; +} + +function hasParentOrGlobalScope( + config?: EventScopeConfig +): config is { scope: Exclude } { + return config?.scope === 'parent' || config?.scope === 'global'; +} diff --git a/modules/signals/events/src/event-creator-group.ts b/modules/signals/events/src/event-creator-group.ts index ecc36a7bd9..f3ce295c2b 100644 --- a/modules/signals/events/src/event-creator-group.ts +++ b/modules/signals/events/src/event-creator-group.ts @@ -10,7 +10,7 @@ type EventCreatorGroup< Source extends string, Events extends Record, > = { - [EventName in keyof Events]: EventName extends string + readonly [EventName in keyof Events]: EventName extends string ? EventCreator, Events[EventName]> : never; }; diff --git a/modules/signals/events/src/event-scope.ts b/modules/signals/events/src/event-scope.ts new file mode 100644 index 0000000000..584b3e9569 --- /dev/null +++ b/modules/signals/events/src/event-scope.ts @@ -0,0 +1,103 @@ +import { map, OperatorFunction } from 'rxjs'; +import { EventInstance } from './event-instance'; + +/** + * @experimental + */ +export type EventScope = 'self' | 'parent' | 'global'; + +/** + * @experimental + */ +export type EventScopeConfig = { scope: EventScope }; + +/** + * @experimental + * @description + * + * Marks a single event to be dispatched in the specified scope. + * Used in a tuple alongside an event to indicate its dispatch scope. + * + * @usageNotes + * + * ```ts + * import { signalStore, type } from '@ngrx/signals'; + * import { event, Events, withEffects } from '@ngrx/signals/events'; + * import { mapResponse } from '@ngrx/operators'; + * + * const opened = event('[Users Page] Opened'); + * const loadedSuccess = event('[Users API] Loaded Success', type()); + * const loadedFailure = event('[Users API] Loaded Failure', type()); + * + * const UsersStore = signalStore( + * withEffects(( + * _, + * events = inject(Events), + * usersService = inject(UsersService) + * ) => ({ + * loadUsers$: events.on(opened).pipe( + * exhaustMap(() => + * usersService.getAll().pipe( + * mapResponse({ + * next: (users) => loadedSuccess(users), + * error: (error: { message: string }) => [ + * loadedFailure(error.message), + * toScope('global'), + * ], + * }), + * ), + * ), + * ), + * })), + * ); + * ``` + */ +export function toScope(scope: EventScope): EventScopeConfig { + return { scope }; +} + +/** + * @experimental + * @description + * + * RxJS operator that maps all emitted events in the stream to be dispatched + * in the specified scope. + * + * @usageNotes + * + * ```ts + * import { signalStore, type } from '@ngrx/signals'; + * import { event, Events, withEffects } from '@ngrx/signals/events'; + * import { mapResponse } from '@ngrx/operators'; + * + * const opened = event('[Users Page] Opened'); + * const loadedSuccess = event('[Users API] Loaded Success', type()); + * const loadedFailure = event('[Users API] Loaded Failure', type()); + * + * const UsersStore = signalStore( + * withEffects(( + * _, + * events = inject(Events), + * usersService = inject(UsersService) + * ) => ({ + * loadUsers$: events.on(opened).pipe( + * exhaustMap(() => + * usersService.getAll().pipe( + * mapResponse({ + * next: (users) => loadedSuccess(users), + * error: (error: { message: string }) => + * loadedFailure(error.message), + * }), + * mapToScope('parent'), + * ), + * ), + * ), + * })), + * ); + * ``` + */ +export function mapToScope>( + scope: EventScope +): OperatorFunction { + return map((event) => [event, toScope(scope)]); +} diff --git a/modules/signals/events/src/events-service.ts b/modules/signals/events/src/events-service.ts index fac17a8ae2..22cb7718f6 100644 --- a/modules/signals/events/src/events-service.ts +++ b/modules/signals/events/src/events-service.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable, Type } from '@angular/core'; import { filter, map, + merge, MonoTypeOperatorFunction, Observable, Subject, @@ -21,6 +22,17 @@ abstract class BaseEvents { * @internal */ readonly [EVENTS] = new Subject>(); + protected readonly events$: Observable>; + + protected constructor(parentEventsToken: Type) { + const parentEvents = inject(parentEventsToken, { + skipSelf: true, + optional: true, + }); + this.events$ = parentEvents + ? merge(parentEvents.events$, this[EVENTS]) + : this[EVENTS].asObservable(); + } on(): Observable>; on[]>( @@ -31,7 +43,7 @@ abstract class BaseEvents { on( ...events: EventCreator[] ): Observable> { - return this[EVENTS].pipe(filterByType(events), withSourceType()); + return this.events$.pipe(filterByType(events), withSourceType()); } } @@ -48,7 +60,7 @@ abstract class BaseEvents { * * const increment = event('[Counter Page] Increment'); * - * \@Component({ \/* ... *\/ }) + * \@Component({ /* ... *\/ }) * class Counter { * readonly #events = inject(Events); * @@ -56,16 +68,32 @@ abstract class BaseEvents { * this.#events * .on(increment) * .pipe(takeUntilDestroyed()) - * .subscribe(() => \/* handle increment event *\/); + * .subscribe(() => /* handle increment event *\/); * } * } * ``` */ @Injectable({ providedIn: 'platform' }) -export class Events extends BaseEvents {} +export class Events extends BaseEvents { + constructor() { + super(Events); + } +} +/** + * @experimental + * @description + * + * Globally provided service for listening to dispatched events. + * Receives events before the `Events` service and is primarily used for + * handling state transitions. + */ @Injectable({ providedIn: 'platform' }) -export class ReducerEvents extends BaseEvents {} +export class ReducerEvents extends BaseEvents { + constructor() { + super(ReducerEvents); + } +} function filterByType>( events: EventCreator[] diff --git a/modules/signals/events/src/index.ts b/modules/signals/events/src/index.ts index 99097d7d4d..171bc4a43a 100644 --- a/modules/signals/events/src/index.ts +++ b/modules/signals/events/src/index.ts @@ -1,9 +1,15 @@ export { on } from './case-reducer'; -export { Dispatcher } from './dispatcher'; +export { Dispatcher, provideDispatcher } from './dispatcher'; export { event, EventCreator } from './event-creator'; export { eventGroup } from './event-creator-group'; export { EventInstance } from './event-instance'; -export { Events } from './events-service'; +export { + EventScope, + EventScopeConfig, + mapToScope, + toScope, +} from './event-scope'; +export { Events, ReducerEvents } from './events-service'; export { injectDispatch } from './inject-dispatch'; export { withEffects } from './with-effects'; export { withReducer } from './with-reducer'; diff --git a/modules/signals/events/src/inject-dispatch.ts b/modules/signals/events/src/inject-dispatch.ts index 905655115b..41feef0788 100644 --- a/modules/signals/events/src/inject-dispatch.ts +++ b/modules/signals/events/src/inject-dispatch.ts @@ -7,13 +7,14 @@ import { import { Prettify } from '@ngrx/signals'; import { Dispatcher } from './dispatcher'; import { EventCreator } from './event-creator'; +import { EventScope, EventScopeConfig } from './event-scope'; -type InjectDispatchResult< +type SelfDispatchingEvents< EventGroup extends Record>, > = { - [EventName in keyof EventGroup]: Parameters extends [ - infer Payload, - ] + readonly [EventName in keyof EventGroup]: Parameters< + EventGroup[EventName] + > extends [infer Payload] ? (payload: Payload) => void : () => void; }; @@ -38,7 +39,7 @@ type InjectDispatchResult< * }, * }); * - * \@Component({ \/* ... *\/ }) + * \@Component({ /* ... *\/ }) * class Counter { * readonly dispatch = injectDispatch(counterPageEvents); * @@ -57,7 +58,8 @@ export function injectDispatch< >( events: EventGroup, config?: { injector?: Injector } -): Prettify> { +): ((config: EventScopeConfig) => Prettify>) & + Prettify> { if (typeof ngDevMode !== 'undefined' && ngDevMode && !config?.injector) { assertInInjectionContext(injectDispatch); } @@ -65,12 +67,38 @@ export function injectDispatch< const injector = config?.injector ?? inject(Injector); const dispatcher = injector.get(Dispatcher); - return Object.entries(events).reduce( - (acc, [eventName, eventCreator]) => ({ + const eventsCache = {} as Record< + EventScope, + SelfDispatchingEvents + >; + + const dispatch = (config: EventScopeConfig) => { + if (!eventsCache[config.scope]) { + eventsCache[config.scope] = Object.entries(events).reduce( + (acc, [eventName, eventCreator]) => ({ + ...acc, + [eventName]: (payload?: unknown) => + untracked(() => dispatcher.dispatch(eventCreator(payload), config)), + }), + {} as SelfDispatchingEvents + ); + } + + return eventsCache[config.scope]; + }; + + const defaultEventGroup = dispatch({ scope: 'self' }); + const defaultEventGroupProps = Object.keys(defaultEventGroup).reduce( + (acc, eventName) => ({ ...acc, - [eventName]: (payload?: unknown) => - untracked(() => dispatcher.dispatch(eventCreator(payload))), + [eventName]: { + value: defaultEventGroup[eventName], + enumerable: true, + }, }), - {} as InjectDispatchResult + {} as PropertyDescriptorMap ); + Object.defineProperties(dispatch, defaultEventGroupProps); + + return dispatch as ReturnType>; } diff --git a/modules/signals/events/src/with-effects.ts b/modules/signals/events/src/with-effects.ts index 3cfd978383..e674746b1c 100644 --- a/modules/signals/events/src/with-effects.ts +++ b/modules/signals/events/src/with-effects.ts @@ -58,9 +58,16 @@ export function withEffects( const effectSources = effectsFactory(store); const effects = Object.values(effectSources).map((effectSource$) => effectSource$.pipe( - tap((value) => { - if (isEventInstance(value) && !(SOURCE_TYPE in value)) { - dispatcher.dispatch(value); + tap((result) => { + const [potentialEvent, config] = Array.isArray(result) + ? result + : [result]; + + if ( + isEventInstance(potentialEvent) && + !(SOURCE_TYPE in potentialEvent) + ) { + dispatcher.dispatch(potentialEvent, config); } }) )