Skip to content

Commit

Permalink
feat(store): enable dispatching actions on signal changes (#4600)
Browse files Browse the repository at this point in the history
Closes #4537
  • Loading branch information
rainerhahnekamp authored Nov 27, 2024
1 parent b57e707 commit 2528d39
Show file tree
Hide file tree
Showing 6 changed files with 268 additions and 22 deletions.
104 changes: 103 additions & 1 deletion modules/store/spec/store.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,6 +17,8 @@ import {
UPDATE,
ActionReducer,
Action,
createAction,
props,
} from '../';
import { StoreConfig } from '../src/store_config';
import { combineReducers } from '../src/utils';
Expand Down Expand Up @@ -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);
});
});
});
50 changes: 44 additions & 6 deletions modules/store/spec/types/store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,57 @@ 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}
`,
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() }));'
);
});
});
});
9 changes: 9 additions & 0 deletions modules/store/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ export function capitalize<T extends string>(text: T): Capitalize<T> {
export function uncapitalize<T extends string>(text: T): Uncapitalize<T> {
return (text.charAt(0).toLowerCase() + text.substring(1)) as Uncapitalize<T>;
}

export function assertDefined<T>(
value: T | null | undefined,
name: string
): asserts value is T {
if (value === null || value === undefined) {
throw new Error(`${name} must be defined.`);
}
}
8 changes: 4 additions & 4 deletions modules/store/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = 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`.
*/
Expand Down
66 changes: 55 additions & 11 deletions modules/store/src/store.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
// 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';

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<T = object>
Expand All @@ -26,7 +37,8 @@ export class Store<T = object>
constructor(
state$: StateObservable,
private actionsObserver: ActionsSubject,
private reducerManager: ReducerManager
private reducerManager: ReducerManager,
private injector?: Injector
) {
super();

Expand Down Expand Up @@ -124,14 +136,21 @@ export class Store<T = object>
return store;
}

dispatch<V extends Action = Action>(
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<V extends Action>(action: V & CreatorsNotAllowedCheck<V>): void;
dispatch<V extends () => Action>(
dispatchFn: V & CreatorsNotAllowedCheck<V>,
config?: {
injector: Injector;
}
): EffectRef;
dispatch<V extends Action | (() => Action)>(
actionOrDispatchFn: V,
config?: { injector?: Injector }
): EffectRef | void {
if (typeof actionOrDispatchFn === 'function') {
return this.processDispatchFn(actionOrDispatchFn, config);
}
this.actionsObserver.next(actionOrDispatchFn);
}

next(action: Action) {
Expand All @@ -156,6 +175,23 @@ export class Store<T = object>
removeReducer<Key extends Extract<keyof T, string>>(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];
Expand Down Expand Up @@ -272,3 +308,11 @@ export function select<T, Props, K>(
return mapped$.pipe(distinctUntilChanged());
};
}

function getCallerInjector() {
try {
return inject(Injector);
} catch (_) {
return undefined;
}
}
53 changes: 53 additions & 0 deletions projects/ngrx.io/content/guide/store/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,59 @@ The returned action has very specific context about where the action came from a

</div>

## Dispatching actions on signal changes

You can also dispatch functions that return actions, with property values derived from signals:

<code-example header="book.component.ts">
class BookComponent {
bookId = input.required&lt;number&gt;();

constructor(store: Store) {
store.dispatch(() => loadBook({ id: this.bookId() })));
}
}
</code-example>

`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:

<code-example header="book.component.ts">
class BookComponent {
bookId = input.required&lt;number&gt;();
injector = inject(Injector);
store = inject(Store);

ngOnInit() {
// runs outside the injection context
this.store.dispatch(() => loadBook({ id: this.bookId() }), { injector: this.injector });
}
}
</code-example>

When passing a function to the `dispatch` method, it returns an `EffectRef`. For manual cleanup, call the `destroy` method on the `EffectRef`:

<code-example header="book.component.ts">
class BookComponent {
bookId = input.required&lt;number&gt;();
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();
}
}
}
</code-example>

## Next Steps

Action's only responsibilities are to express unique events and intents. Learn how they are handled in the guides below.
Expand Down

0 comments on commit 2528d39

Please sign in to comment.