From 8ecac430969a23c9d63bf54f36436ea0b184d6c3 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Fri, 22 Nov 2024 01:00:25 +0100 Subject: [PATCH] feat: `dispatch` accepts a function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatch` now accepts a function that returns an `Action`. Providing a `Signal` will still be possible (`Signal` is also a function). I had to rename the type `FunctionIsNotAllowed` to `NoActionCreator`. Previously, `FunctionIsNotAllowed` was used to prevent passing functions to `dispatch`, but the primary intent was to forbid `ActionCreator`. ```typescript store.dispatch(loadBook); // 👎 store.dispatch(loadBook({id: 1})); // 👍 ``` As the name says, `NoActionCreator` now explicitly forbids only `ActionCreator`. Functions are fine. Otherwise, the new feature would not be possible. --- modules/store/spec/store.spec.ts | 28 +++++++------- modules/store/spec/types/store.spec.ts | 2 +- modules/store/src/models.ts | 10 +++-- modules/store/src/store.ts | 38 +++++++++---------- .../ngrx.io/content/guide/store/actions.md | 9 ++--- 5 files changed, 43 insertions(+), 44 deletions(-) diff --git a/modules/store/spec/store.spec.ts b/modules/store/spec/store.spec.ts index 9601c941bf..3efa31f09e 100644 --- a/modules/store/spec/store.spec.ts +++ b/modules/store/spec/store.spec.ts @@ -1,5 +1,4 @@ import { - computed, createEnvironmentInjector, EnvironmentInjector, InjectionToken, @@ -18,6 +17,8 @@ import { UPDATE, ActionReducer, Action, + createAction, + props, } from '../'; import { StoreConfig } from '../src/store_config'; import { combineReducers } from '../src/utils'; @@ -717,10 +718,7 @@ describe('ngRx Store', () => { store = TestBed.inject(Store); const inputId = signal(1); - const incrementerAction = computed(() => ({ - type: INCREMENT, - id: inputId(), - })); + const increment = createAction('INCREMENT', props<{ id: number }>()); const changeInputIdAndFlush = () => { inputId.update((value) => value + 1); @@ -729,16 +727,16 @@ describe('ngRx Store', () => { const stateSignal = store.selectSignal((state) => state.counter1); - return { inputId, incrementerAction, stateSignal, changeInputIdAndFlush }; + return { inputId, increment, stateSignal, changeInputIdAndFlush }; }; it('should dispatch upon Signal change', () => { - const { inputId, incrementerAction, changeInputIdAndFlush, stateSignal } = + const { inputId, increment, changeInputIdAndFlush, stateSignal } = setupForSignalDispatcher(); expect(stateSignal()).toBe(0); - store.dispatch(incrementerAction); + store.dispatch(() => increment({ id: inputId() })); TestBed.flushEffects(); expect(stateSignal()).toBe(1); @@ -756,10 +754,10 @@ describe('ngRx Store', () => { }); it('should stop dispatching once the effect is destroyed', () => { - const { incrementerAction, changeInputIdAndFlush, stateSignal } = + const { increment, changeInputIdAndFlush, stateSignal, inputId } = setupForSignalDispatcher(); - const ref = store.dispatch(incrementerAction); + const ref = store.dispatch(() => increment({ id: inputId() })); TestBed.flushEffects(); ref.destroy(); @@ -768,7 +766,7 @@ describe('ngRx Store', () => { }); it('should use the injectionContext of the caller if available', () => { - const { incrementerAction, changeInputIdAndFlush, stateSignal } = + const { increment, changeInputIdAndFlush, stateSignal, inputId } = setupForSignalDispatcher(); const callerContext = createEnvironmentInjector( @@ -776,7 +774,7 @@ describe('ngRx Store', () => { TestBed.inject(EnvironmentInjector) ); runInInjectionContext(callerContext, () => - store.dispatch(incrementerAction) + store.dispatch(() => increment({ id: inputId() })) ); TestBed.flushEffects(); @@ -788,13 +786,15 @@ describe('ngRx Store', () => { }); it('should allow to override the injectionContext of the caller', () => { - const { incrementerAction, changeInputIdAndFlush, stateSignal } = + const { increment, changeInputIdAndFlush, stateSignal, inputId } = setupForSignalDispatcher(); const environmentInjector = TestBed.inject(EnvironmentInjector); const callerContext = createEnvironmentInjector([], environmentInjector); runInInjectionContext(callerContext, () => - store.dispatch(incrementerAction, { injector: environmentInjector }) + store.dispatch(() => increment({ id: inputId() }), { + injector: environmentInjector, + }) ); TestBed.flushEffects(); diff --git a/modules/store/spec/types/store.spec.ts b/modules/store/spec/types/store.spec.ts index b04fd475f3..0c338cdfc7 100644 --- a/modules/store/spec/types/store.spec.ts +++ b/modules/store/spec/types/store.spec.ts @@ -16,7 +16,7 @@ describe('Store', () => { 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/ + /is not assignable to type '"action creators are not allowed to be dispatched. Did you forget to call it/ ); }); }); diff --git a/modules/store/src/models.ts b/modules/store/src/models.ts index 69711908f7..df5e102e0b 100644 --- a/modules/store/src/models.ts +++ b/modules/store/src/models.ts @@ -80,10 +80,12 @@ 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 NoActionCreator = T extends Action + ? T extends () => Action + ? 'action creators are not allowed to be dispatched. Did you forget to call it?' + : T + : T; + /** * 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 0dcf83067e..bda050bf87 100644 --- a/modules/store/src/store.ts +++ b/modules/store/src/store.ts @@ -6,7 +6,6 @@ import { inject, Injectable, Injector, - isSignal, Provider, Signal, untracked, @@ -18,8 +17,8 @@ import { ActionsSubject } from './actions_subject'; import { Action, ActionReducer, + NoActionCreator, SelectSignalOptions, - FunctionIsNotAllowed, } from './models'; import { ReducerManager } from './reducer_manager'; import { StateObservable } from './state'; @@ -137,23 +136,18 @@ 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?' - > - ): void; - dispatch(action: Signal, config?: { injector: Injector }): EffectRef; - dispatch( - action: (V | Signal) & - FunctionIsNotAllowed< - V, - 'Functions are not allowed to be dispatched. Did you forget to call the action creator function?' - >, + dispatch(action: V & NoActionCreator): void; + dispatch Action>( + actionFn: V & NoActionCreator, + config?: { + injector: Injector; + } + ): EffectRef; + dispatch Action)>( + action: V & NotActionCreator, config?: { injector: Injector } ): EffectRef | void { - if (isSignal(action)) { + if (typeof action === 'function') { return this.processSignalToDispatch(action, config); } this.actionsObserver.next(action); @@ -183,7 +177,7 @@ export class Store } private processSignalToDispatch( - actionSignal: Signal, + actionFn: () => Action, config?: { injector: Injector } ) { assertNotUndefined(this.injector); @@ -192,7 +186,7 @@ export class Store return effect( () => { - const action = actionSignal(); + const action = actionFn(); untracked(() => { this.dispatch(action); }); @@ -324,3 +318,9 @@ function getCallerInjector() { return undefined; } } + +type NotActionCreator = T extends Action + ? T extends () => Action + ? ErrorMessage + : T + : T; diff --git a/projects/ngrx.io/content/guide/store/actions.md b/projects/ngrx.io/content/guide/store/actions.md index 910d5cdff7..6bd720cc9f 100644 --- a/projects/ngrx.io/content/guide/store/actions.md +++ b/projects/ngrx.io/content/guide/store/actions.md @@ -92,10 +92,9 @@ There is also the option to dispatch a Signal of type `Signal`: class BookComponent { bookId = input.required<number>(); - loadBookAction = computed(() => loadBook({ id: this.bookId() })); constructor(store: Store) { - store.dispatch(this.loadBookAction); + store.dispatch(() => loadBook({ id: this.bookId() }))); } } @@ -109,12 +108,11 @@ Alternatively, you can provide your own injection context: class BookComponent { bookId = input.required<number>(); - loadBookAction = computed(() => loadBook({ id: this.bookId() })); injector = inject(Injector); ngOnInit(store: Store) { // runs outside the injection context - store.dispatch(this.loadBookAction, {injector: this.injector}); + store.dispatch(() => loadBook({ id: this.bookId() }), {injector: this.injector}); } } @@ -124,12 +122,11 @@ class BookComponent { class BookComponent { bookId = input.required<number>(); - loadBookAction = computed(() => loadBook({ id: this.bookId() })); loadBookEffectRef: EffectRef | undefined; ngOnInit(store: Store) { // uses the injection context of Store, i.e. root injector - this.loadBookEffectRef = store.dispatch(this.loadBookAction); + this.loadBookEffectRef = store.dispatch(() => loadBook({ id: this.bookId() })); } ngOnDestroy() {