diff --git a/modules/component-store/spec/component-store.spec.ts b/modules/component-store/spec/component-store.spec.ts index 121f6192c3..4c62531275 100644 --- a/modules/component-store/spec/component-store.spec.ts +++ b/modules/component-store/spec/component-store.spec.ts @@ -6,6 +6,7 @@ import { InjectionToken, Injector, Provider, + ValueEqualityFn, } from '@angular/core'; import { fakeAsync, flushMicrotasks } from '@angular/core/testing'; import { @@ -1382,6 +1383,45 @@ describe('Component Store', () => { }); }); + describe('selector with custom equal fn', () => { + interface State { + obj: StateValue; + updated?: boolean; + } + interface StateValue { + value: string; + } + + const equal: ValueEqualityFn = (a, b) => a.value === b.value; + const INIT_STATE: State = { obj: { value: 'init' } }; + let componentStore: ComponentStore; + + beforeEach(() => { + componentStore = new ComponentStore(INIT_STATE); + }); + + it( + 'does not emit the same value if it did not change', + marbles((m) => { + const selector = componentStore.select((s) => s.obj, { + equal, + }); + + const selectorResults: string[] = []; + selector.subscribe((value) => { + selectorResults.push(value.value); + }); + + m.flush(); + componentStore.setState(() => ({ obj: { value: 'new value' } })); + componentStore.setState(() => ({ obj: { value: 'new value' } })); // 👈 emit twice + + m.flush(); + expect(selectorResults).toEqual(['init', 'new value']); // capture only one change + }) + ); + }); + describe('selectSignal', () => { it('creates a signal from the provided state projector function', () => { const store = new ComponentStore<{ foo: string }>({ foo: 'bar' }); diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index 3a793eb14c..bad73c4797 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -40,8 +40,9 @@ import { import { isOnStateInitDefined, isOnStoreInitDefined } from './lifecycle_hooks'; import { toSignal } from '@angular/core/rxjs-interop'; -export interface SelectConfig { +export interface SelectConfig { debounce?: boolean; + equal?: ValueEqualityFn; } export const INITIAL_STATE_TOKEN = new InjectionToken( @@ -248,11 +249,13 @@ export class ComponentStore implements OnDestroy { */ select( projector: (s: T) => Result, - config?: SelectConfig + config?: SelectConfig ): Observable; select>>( selectorsObject: SelectorsObject, - config?: SelectConfig + config?: SelectConfig<{ + [K in keyof SelectorsObject]: ObservedValueOf; + }> ): Observable<{ [K in keyof SelectorsObject]: ObservedValueOf; }>; @@ -266,12 +269,12 @@ export class ComponentStore implements OnDestroy { ...selectorsWithProjectorAndConfig: [ ...selectors: Selectors, projector: Projector, - config: SelectConfig + config: SelectConfig ] ): Observable; select< Selectors extends Array< - Observable | SelectConfig | ProjectorFn | SelectorsObject + Observable | SelectConfig | ProjectorFn | SelectorsObject >, Result, ProjectorFn extends (...a: unknown[]) => Result, @@ -297,7 +300,7 @@ export class ComponentStore implements OnDestroy { : projector(projectorArgs) ) : noopOperator()) as () => Observable, - distinctUntilChanged(), + distinctUntilChanged(config.equal), shareReplay({ refCount: true, bufferSize: 1, @@ -456,7 +459,7 @@ export class ComponentStore implements OnDestroy { function processSelectorArgs< Selectors extends Array< - Observable | SelectConfig | ProjectorFn | SelectorsObject + Observable | SelectConfig | ProjectorFn | SelectorsObject >, Result, ProjectorFn extends (...a: unknown[]) => Result, @@ -467,16 +470,22 @@ function processSelectorArgs< | { observablesOrSelectorsObject: Observable[]; projector: ProjectorFn; - config: Required; + config: Required>; } | { observablesOrSelectorsObject: SelectorsObject; projector: undefined; - config: Required; + config: Required>; } { const selectorArgs = Array.from(args); + const defaultEqualityFn: ValueEqualityFn = (previous, current) => + previous === current; + // Assign default values. - let config: Required = { debounce: false }; + let config: Required> = { + debounce: false, + equal: defaultEqualityFn, + }; // Last argument is either config or projector or selectorsObject if (isSelectConfig(selectorArgs[selectorArgs.length - 1])) { @@ -504,8 +513,14 @@ function processSelectorArgs< }; } -function isSelectConfig(arg: SelectConfig | unknown): arg is SelectConfig { - return typeof (arg as SelectConfig).debounce !== 'undefined'; +function isSelectConfig( + arg: SelectConfig | unknown +): arg is SelectConfig { + const typedArg = arg as SelectConfig; + return ( + typeof typedArg.debounce !== 'undefined' || + typeof typedArg.equal !== 'undefined' + ); } function hasProjectFnOnly( diff --git a/projects/ngrx.io/content/guide/component-store/read.md b/projects/ngrx.io/content/guide/component-store/read.md index 40631e675a..6ccaa57efd 100644 --- a/projects/ngrx.io/content/guide/component-store/read.md +++ b/projects/ngrx.io/content/guide/component-store/read.md @@ -137,6 +137,28 @@ export class MoviesStore extends ComponentStore<MoviesState> { } +## Using a custom equality function + +The observable created by the `select` method compares the newly emitted value with the previous one using the default equality check (`===`) and emits only if the value has changed. However, the default behavior can be overridden by passing a custom equality function to the `select` method config. + + +export interface MoviesState { + movies: Movie[]; +} + +@Injectable() +export class MoviesStore extends ComponentStore<MoviesState> { + + constructor() { + super({movies:[]}); + } + + readonly movies$: Observable<Movie[]> = this.select( + state => state.movies, + {equal: (prev, curr) => prev.length === curr.length} // 👈 custom equality function + ); +} + ## Selecting from global `@ngrx/store`