diff --git a/modules/store/spec/store.spec.ts b/modules/store/spec/store.spec.ts index ee96314417..3efa31f09e 100644 --- a/modules/store/spec/store.spec.ts +++ b/modules/store/spec/store.spec.ts @@ -1,4 +1,10 @@ -import { InjectionToken } from '@angular/core'; +import { + createEnvironmentInjector, + EnvironmentInjector, + InjectionToken, + runInInjectionContext, + signal, +} from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { hot } from 'jasmine-marbles'; import { @@ -11,6 +17,8 @@ import { UPDATE, ActionReducer, Action, + createAction, + props, } from '../'; import { StoreConfig } from '../src/store_config'; import { combineReducers } from '../src/utils'; @@ -703,4 +711,98 @@ describe('ngRx Store', () => { expect(metaReducerSpy2).toHaveBeenCalledWith(counterReducer2); }); }); + + describe('Signal Dispatcher', () => { + const setupForSignalDispatcher = () => { + setup(); + store = TestBed.inject(Store); + + const inputId = signal(1); + const increment = createAction('INCREMENT', props<{ id: number }>()); + + const changeInputIdAndFlush = () => { + inputId.update((value) => value + 1); + TestBed.flushEffects(); + }; + + const stateSignal = store.selectSignal((state) => state.counter1); + + return { inputId, increment, stateSignal, changeInputIdAndFlush }; + }; + + it('should dispatch upon Signal change', () => { + const { inputId, increment, changeInputIdAndFlush, stateSignal } = + setupForSignalDispatcher(); + + expect(stateSignal()).toBe(0); + + store.dispatch(() => increment({ id: inputId() })); + TestBed.flushEffects(); + expect(stateSignal()).toBe(1); + + changeInputIdAndFlush(); + expect(stateSignal()).toBe(2); + + inputId.update((value) => value + 1); + expect(stateSignal()).toBe(2); + + TestBed.flushEffects(); + expect(stateSignal()).toBe(3); + + TestBed.flushEffects(); + expect(stateSignal()).toBe(3); + }); + + it('should stop dispatching once the effect is destroyed', () => { + const { increment, changeInputIdAndFlush, stateSignal, inputId } = + setupForSignalDispatcher(); + + const ref = store.dispatch(() => increment({ id: inputId() })); + TestBed.flushEffects(); + + ref.destroy(); + changeInputIdAndFlush(); + expect(stateSignal()).toBe(1); + }); + + it('should use the injectionContext of the caller if available', () => { + const { increment, changeInputIdAndFlush, stateSignal, inputId } = + setupForSignalDispatcher(); + + const callerContext = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + runInInjectionContext(callerContext, () => + store.dispatch(() => increment({ id: inputId() })) + ); + + TestBed.flushEffects(); + expect(stateSignal()).toBe(1); + + callerContext.destroy(); + changeInputIdAndFlush(); + expect(stateSignal()).toBe(1); + }); + + it('should allow to override the injectionContext of the caller', () => { + const { increment, changeInputIdAndFlush, stateSignal, inputId } = + setupForSignalDispatcher(); + + const environmentInjector = TestBed.inject(EnvironmentInjector); + const callerContext = createEnvironmentInjector([], environmentInjector); + runInInjectionContext(callerContext, () => + store.dispatch(() => increment({ id: inputId() }), { + injector: environmentInjector, + }) + ); + + TestBed.flushEffects(); + expect(stateSignal()).toBe(1); + + callerContext.destroy(); + changeInputIdAndFlush(); + expect(stateSignal()).toBe(2); + }); + }); }); diff --git a/modules/store/spec/types/store.spec.ts b/modules/store/spec/types/store.spec.ts index b04fd475f3..bc35d5af1f 100644 --- a/modules/store/spec/types/store.spec.ts +++ b/modules/store/spec/types/store.spec.ts @@ -4,9 +4,15 @@ import { compilerOptions } from './utils'; describe('Store', () => { const expectSnippet = expecter( (code) => ` - import { Store, createAction } '@ngrx/store'; + import { Store, createAction, props } from '@ngrx/store'; + import { inject, signal } from '@angular/core'; - const store = {} as Store<{}>; + const load = createAction('load'); + const incrementer = createAction('increment', props<{value: number}>()); + + const value = signal(1); + + const store = inject(Store); const fooAction = createAction('foo') ${code} @@ -14,9 +20,41 @@ describe('Store', () => { compilerOptions() ); - it('should not allow passing action creator function without calling it', () => { - expectSnippet(`store.dispatch(fooAction);`).toFail( - /is not assignable to type '"Functions are not allowed to be dispatched. Did you forget to call the action creator function/ - ); + describe('compilation fails', () => { + const assertCompilationFailure = (code: string) => + expectSnippet(code).toFail( + /is not assignable to type '"Action creator is not allowed to be dispatched. Did you forget to call it/ + ); + + it('does not allow dispatching action creators without props', () => { + assertCompilationFailure('store.dispatch(load);'); + }); + + it('does not allow dispatching action creators with props', () => { + assertCompilationFailure('store.dispatch(incrementer);'); + }); + }); + + describe('compilation succeeds', () => { + const assertCompilationSuccess = (code: string) => + expectSnippet(code).toSucceed(); + + it('allows dispatching actions without props', () => { + assertCompilationSuccess('store.dispatch(load());'); + }); + + it('allows dispatching actions with props', () => { + assertCompilationSuccess('store.dispatch(incrementer({ value: 1 }));'); + }); + + it('allows dispatching a function returning an action without props', () => { + assertCompilationSuccess('store.dispatch(() => load());'); + }); + + it('allows dispatching a function returning an action with props ', () => { + assertCompilationSuccess( + 'store.dispatch(() => incrementer({ value: value() }));' + ); + }); }); }); diff --git a/modules/store/src/helpers.ts b/modules/store/src/helpers.ts index a2efac6f6f..2a9542e479 100644 --- a/modules/store/src/helpers.ts +++ b/modules/store/src/helpers.ts @@ -5,3 +5,12 @@ export function capitalize(text: T): Capitalize { export function uncapitalize(text: T): Uncapitalize { return (text.charAt(0).toLowerCase() + text.substring(1)) as Uncapitalize; } + +export function assertDefined( + value: T | null | undefined, + name: string +): asserts value is T { + if (value === null || value === undefined) { + throw new Error(`${name} must be defined.`); + } +} diff --git a/modules/store/src/models.ts b/modules/store/src/models.ts index 69711908f7..6126816b95 100644 --- a/modules/store/src/models.ts +++ b/modules/store/src/models.ts @@ -80,10 +80,10 @@ export const primitivesAreNotAllowedInProps = 'action creator props cannot be a primitive value'; type PrimitivesAreNotAllowedInProps = typeof primitivesAreNotAllowedInProps; -export type FunctionIsNotAllowed< - T, - ErrorMessage extends string -> = T extends Function ? ErrorMessage : T; +export type CreatorsNotAllowedCheck = T extends ActionCreator + ? 'Action creator is not allowed to be dispatched. Did you forget to call it?' + : unknown; + /** * A function that returns an object in the shape of the `Action` interface. Configured using `createAction`. */ diff --git a/modules/store/src/store.ts b/modules/store/src/store.ts index 01024d8e51..d03f4161ed 100644 --- a/modules/store/src/store.ts +++ b/modules/store/src/store.ts @@ -1,5 +1,15 @@ // disabled because we have lowercase generics for `select` -import { computed, Injectable, Provider, Signal } from '@angular/core'; +import { + computed, + effect, + EffectRef, + inject, + Injectable, + Injector, + Provider, + Signal, + untracked, +} from '@angular/core'; import { Observable, Observer, Operator } from 'rxjs'; import { distinctUntilChanged, map, pluck } from 'rxjs/operators'; @@ -7,11 +17,12 @@ import { ActionsSubject } from './actions_subject'; import { Action, ActionReducer, + CreatorsNotAllowedCheck, SelectSignalOptions, - FunctionIsNotAllowed, } from './models'; import { ReducerManager } from './reducer_manager'; import { StateObservable } from './state'; +import { assertDefined } from './helpers'; @Injectable() export class Store @@ -26,7 +37,8 @@ export class Store constructor( state$: StateObservable, private actionsObserver: ActionsSubject, - private reducerManager: ReducerManager + private reducerManager: ReducerManager, + private injector?: Injector ) { super(); @@ -124,14 +136,21 @@ export class Store return store; } - dispatch( - action: V & - FunctionIsNotAllowed< - V, - 'Functions are not allowed to be dispatched. Did you forget to call the action creator function?' - > - ) { - this.actionsObserver.next(action); + dispatch(action: V & CreatorsNotAllowedCheck): void; + dispatch Action>( + dispatchFn: V & CreatorsNotAllowedCheck, + config?: { + injector: Injector; + } + ): EffectRef; + dispatch Action)>( + actionOrDispatchFn: V, + config?: { injector?: Injector } + ): EffectRef | void { + if (typeof actionOrDispatchFn === 'function') { + return this.processDispatchFn(actionOrDispatchFn, config); + } + this.actionsObserver.next(actionOrDispatchFn); } next(action: Action) { @@ -156,6 +175,23 @@ export class Store removeReducer>(key: Key) { this.reducerManager.removeReducer(key); } + + private processDispatchFn( + dispatchFn: () => Action, + config?: { injector?: Injector } + ) { + assertDefined(this.injector, 'Store Injector'); + const effectInjector = + config?.injector ?? getCallerInjector() ?? this.injector; + + return effect( + () => { + const action = dispatchFn(); + untracked(() => this.dispatch(action)); + }, + { injector: effectInjector } + ); + } } export const STORE_PROVIDERS: Provider[] = [Store]; @@ -272,3 +308,11 @@ export function select( return mapped$.pipe(distinctUntilChanged()); }; } + +function getCallerInjector() { + try { + return inject(Injector); + } catch (_) { + return undefined; + } +} diff --git a/projects/ngrx.io/content/guide/store/actions.md b/projects/ngrx.io/content/guide/store/actions.md index 709688b3ad..a8760b952b 100644 --- a/projects/ngrx.io/content/guide/store/actions.md +++ b/projects/ngrx.io/content/guide/store/actions.md @@ -85,6 +85,59 @@ The returned action has very specific context about where the action came from a +## Dispatching actions on signal changes + +You can also dispatch functions that return actions, with property values derived from signals: + + +class BookComponent { + bookId = input.required<number>(); + + constructor(store: Store) { + store.dispatch(() => loadBook({ id: this.bookId() }))); + } +} + + +`dispatch` executes initially and every time the `bookId` changes. If `dispatch` is called within an injection context, the signal is tracked until the context is destroyed. In the example above, that would be when `BookComponent` is destroyed. + +When `dispatch` is called outside a component's injection context, the signal is tracked globally throughout the application's lifecycle. To ensure proper cleanup in such a case, provide the component's injector to the `dispatch` method: + + +class BookComponent { + bookId = input.required<number>(); + injector = inject(Injector); + store = inject(Store); + + ngOnInit() { + // runs outside the injection context + this.store.dispatch(() => loadBook({ id: this.bookId() }), { injector: this.injector }); + } +} + + +When passing a function to the `dispatch` method, it returns an `EffectRef`. For manual cleanup, call the `destroy` method on the `EffectRef`: + + +class BookComponent { + bookId = input.required<number>(); + loadBookEffectRef: EffectRef | undefined; + store = inject(Store); + + ngOnInit() { + // uses the injection context of Store, i.e. root injector + this.loadBookEffectRef = this.store.dispatch(() => loadBook({ id: this.bookId() })); + } + + ngOnDestroy() { + if (this.loadBookEffectRef) { + // destroys the effect + this.loadBookEffectRef.destroy(); + } + } +} + + ## Next Steps Action's only responsibilities are to express unique events and intents. Learn how they are handled in the guides below.