Skip to content

Commit

Permalink
feat(component-store): added custom equal option in select (#3933)
Browse files Browse the repository at this point in the history
  • Loading branch information
rosostolato authored Jun 21, 2023
1 parent 3ebb6fb commit c4b5cc5
Show file tree
Hide file tree
Showing 3 changed files with 89 additions and 12 deletions.
40 changes: 40 additions & 0 deletions modules/component-store/spec/component-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
InjectionToken,
Injector,
Provider,
ValueEqualityFn,
} from '@angular/core';
import { fakeAsync, flushMicrotasks } from '@angular/core/testing';
import {
Expand Down Expand Up @@ -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<StateValue> = (a, b) => a.value === b.value;
const INIT_STATE: State = { obj: { value: 'init' } };
let componentStore: ComponentStore<State>;

beforeEach(() => {
componentStore = new ComponentStore<State>(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' });
Expand Down
39 changes: 27 additions & 12 deletions modules/component-store/src/component-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ import {
import { isOnStateInitDefined, isOnStoreInitDefined } from './lifecycle_hooks';
import { toSignal } from '@angular/core/rxjs-interop';

export interface SelectConfig {
export interface SelectConfig<T = unknown> {
debounce?: boolean;
equal?: ValueEqualityFn<T>;
}

export const INITIAL_STATE_TOKEN = new InjectionToken(
Expand Down Expand Up @@ -248,11 +249,13 @@ export class ComponentStore<T extends object> implements OnDestroy {
*/
select<Result>(
projector: (s: T) => Result,
config?: SelectConfig
config?: SelectConfig<Result>
): Observable<Result>;
select<SelectorsObject extends Record<string, Observable<unknown>>>(
selectorsObject: SelectorsObject,
config?: SelectConfig
config?: SelectConfig<{
[K in keyof SelectorsObject]: ObservedValueOf<SelectorsObject[K]>;
}>
): Observable<{
[K in keyof SelectorsObject]: ObservedValueOf<SelectorsObject[K]>;
}>;
Expand All @@ -266,12 +269,12 @@ export class ComponentStore<T extends object> implements OnDestroy {
...selectorsWithProjectorAndConfig: [
...selectors: Selectors,
projector: Projector<Selectors, Result>,
config: SelectConfig
config: SelectConfig<Result>
]
): Observable<Result>;
select<
Selectors extends Array<
Observable<unknown> | SelectConfig | ProjectorFn | SelectorsObject
Observable<unknown> | SelectConfig<Result> | ProjectorFn | SelectorsObject
>,
Result,
ProjectorFn extends (...a: unknown[]) => Result,
Expand All @@ -297,7 +300,7 @@ export class ComponentStore<T extends object> implements OnDestroy {
: projector(projectorArgs)
)
: noopOperator()) as () => Observable<Result>,
distinctUntilChanged(),
distinctUntilChanged(config.equal),
shareReplay({
refCount: true,
bufferSize: 1,
Expand Down Expand Up @@ -456,7 +459,7 @@ export class ComponentStore<T extends object> implements OnDestroy {

function processSelectorArgs<
Selectors extends Array<
Observable<unknown> | SelectConfig | ProjectorFn | SelectorsObject
Observable<unknown> | SelectConfig<Result> | ProjectorFn | SelectorsObject
>,
Result,
ProjectorFn extends (...a: unknown[]) => Result,
Expand All @@ -467,16 +470,22 @@ function processSelectorArgs<
| {
observablesOrSelectorsObject: Observable<unknown>[];
projector: ProjectorFn;
config: Required<SelectConfig>;
config: Required<SelectConfig<Result>>;
}
| {
observablesOrSelectorsObject: SelectorsObject;
projector: undefined;
config: Required<SelectConfig>;
config: Required<SelectConfig<Result>>;
} {
const selectorArgs = Array.from(args);
const defaultEqualityFn: ValueEqualityFn<Result> = (previous, current) =>
previous === current;

// Assign default values.
let config: Required<SelectConfig> = { debounce: false };
let config: Required<SelectConfig<Result>> = {
debounce: false,
equal: defaultEqualityFn,
};

// Last argument is either config or projector or selectorsObject
if (isSelectConfig(selectorArgs[selectorArgs.length - 1])) {
Expand Down Expand Up @@ -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> | unknown
): arg is SelectConfig<unknown> {
const typedArg = arg as SelectConfig<unknown>;
return (
typeof typedArg.debounce !== 'undefined' ||
typeof typedArg.equal !== 'undefined'
);
}

function hasProjectFnOnly(
Expand Down
22 changes: 22 additions & 0 deletions projects/ngrx.io/content/guide/component-store/read.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,28 @@ export class MoviesStore extends ComponentStore&lt;MoviesState&gt; {
}
</code-example>

## 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.

<code-example header="movies.store.ts">
export interface MoviesState {
movies: Movie[];
}

@Injectable()
export class MoviesStore extends ComponentStore&lt;MoviesState&gt; {

constructor() {
super({movies:[]});
}

readonly movies$: Observable&lt;Movie[]&gt; = this.select(
state => state.movies,
{equal: (prev, curr) => prev.length === curr.length} // 👈 custom equality function
);
}
</code-example>

## Selecting from global `@ngrx/store`

Expand Down

0 comments on commit c4b5cc5

Please sign in to comment.