From e8a3c242bd6ea942f3341cb9da000078dcf604ca Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Thu, 13 Jun 2024 16:39:16 -0700 Subject: [PATCH] feat(core): add equality function to rxjs-interop `toSignal` `toSignal` predates the decision to allow a more flexible equality check in signals, and thus doesn't support a custom equality function. This commit adds the ability to pass a custom value equality function. As a side effect, it now adds the default equality check where it wasn't used before. Fixes #55573 --- .../public-api/core/rxjs-interop/index.api.md | 12 +++--- packages/core/rxjs-interop/src/to_signal.ts | 39 +++++++++++++++---- .../core/rxjs-interop/test/to_signal_spec.ts | 32 +++++++++++++++ 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/goldens/public-api/core/rxjs-interop/index.api.md b/goldens/public-api/core/rxjs-interop/index.api.md index a7cb62819c91..5a9e740f0e9e 100644 --- a/goldens/public-api/core/rxjs-interop/index.api.md +++ b/goldens/public-api/core/rxjs-interop/index.api.md @@ -12,6 +12,7 @@ import { OutputOptions } from '@angular/core'; import { OutputRef } from '@angular/core'; import { Signal } from '@angular/core'; import { Subscribable } from 'rxjs'; +import { ValueEqualityFn } from '@angular/core/primitives/signals'; // @public export function outputFromObservable(observable: Observable, opts?: OutputOptions): OutputRef; @@ -34,31 +35,32 @@ export interface ToObservableOptions { export function toSignal(source: Observable | Subscribable): Signal; // @public (undocumented) -export function toSignal(source: Observable | Subscribable, options: ToSignalOptions & { +export function toSignal(source: Observable | Subscribable, options: NoInfer> & { initialValue?: undefined; requireSync?: false; }): Signal; // @public (undocumented) -export function toSignal(source: Observable | Subscribable, options: ToSignalOptions & { +export function toSignal(source: Observable | Subscribable, options: NoInfer> & { initialValue?: null; requireSync?: false; }): Signal; // @public (undocumented) -export function toSignal(source: Observable | Subscribable, options: ToSignalOptions & { +export function toSignal(source: Observable | Subscribable, options: NoInfer> & { initialValue?: undefined; requireSync: true; }): Signal; // @public (undocumented) -export function toSignal(source: Observable | Subscribable, options: ToSignalOptions & { +export function toSignal(source: Observable | Subscribable, options: NoInfer> & { initialValue: U; requireSync?: false; }): Signal; // @public -export interface ToSignalOptions { +export interface ToSignalOptions { + equals?: ValueEqualityFn; initialValue?: unknown; injector?: Injector; manualCleanup?: boolean; diff --git a/packages/core/rxjs-interop/src/to_signal.ts b/packages/core/rxjs-interop/src/to_signal.ts index 260aa1da9b14..f131db8db21f 100644 --- a/packages/core/rxjs-interop/src/to_signal.ts +++ b/packages/core/rxjs-interop/src/to_signal.ts @@ -19,6 +19,7 @@ import { ɵRuntimeError, ɵRuntimeErrorCode, } from '@angular/core'; +import {ValueEqualityFn} from '@angular/core/primitives/signals'; import {Observable, Subscribable} from 'rxjs'; /** @@ -26,7 +27,7 @@ import {Observable, Subscribable} from 'rxjs'; * * @publicApi */ -export interface ToSignalOptions { +export interface ToSignalOptions { /** * Initial value for the signal produced by `toSignal`. * @@ -70,6 +71,13 @@ export interface ToSignalOptions { * the behavior of the `async` pipe. */ rejectErrors?: boolean; + + /** + * A comparison function which defines equality for values emitted by the observable. + * + * Equality comparisons are executed against the initial value if one is provided. + */ + equals?: ValueEqualityFn; } // Base case: no options -> `undefined` in the result type. @@ -77,22 +85,25 @@ export function toSignal(source: Observable | Subscribable): Signal `undefined`. export function toSignal( source: Observable | Subscribable, - options: ToSignalOptions & {initialValue?: undefined; requireSync?: false}, + options: NoInfer> & { + initialValue?: undefined; + requireSync?: false; + }, ): Signal; // Options with `null` initial value -> `null`. export function toSignal( source: Observable | Subscribable, - options: ToSignalOptions & {initialValue?: null; requireSync?: false}, + options: NoInfer> & {initialValue?: null; requireSync?: false}, ): Signal; // Options with `undefined` initial value and `requiredSync` -> strict result type. export function toSignal( source: Observable | Subscribable, - options: ToSignalOptions & {initialValue?: undefined; requireSync: true}, + options: NoInfer> & {initialValue?: undefined; requireSync: true}, ): Signal; // Options with a more specific initial value type. export function toSignal( source: Observable | Subscribable, - options: ToSignalOptions & {initialValue: U; requireSync?: false}, + options: NoInfer> & {initialValue: U; requireSync?: false}, ): Signal; /** @@ -121,7 +132,7 @@ export function toSignal( */ export function toSignal( source: Observable | Subscribable, - options?: ToSignalOptions & {initialValue?: U}, + options?: ToSignalOptions & {initialValue?: U}, ): Signal { ngDevMode && assertNotInReactiveContext( @@ -136,15 +147,20 @@ export function toSignal( ? options?.injector?.get(DestroyRef) ?? inject(DestroyRef) : null; + const equal = makeToSignalEquals(options?.equals); + // Note: T is the Observable value type, and U is the initial value type. They don't have to be // the same - the returned signal gives values of type `T`. let state: WritableSignal>; if (options?.requireSync) { // Initially the signal is in a `NoValue` state. - state = signal({kind: StateKind.NoValue}); + state = signal({kind: StateKind.NoValue}, {equal}); } else { // If an initial value was passed, use it. Otherwise, use `undefined` as the initial value. - state = signal>({kind: StateKind.Value, value: options?.initialValue as U}); + state = signal>( + {kind: StateKind.Value, value: options?.initialValue as U}, + {equal}, + ); } // Note: This code cannot run inside a reactive context (see assertion above). If we'd support @@ -197,6 +213,13 @@ export function toSignal( }); } +function makeToSignalEquals( + userEquality: ValueEqualityFn = Object.is, +): ValueEqualityFn> { + return (a, b) => + a.kind === StateKind.Value && b.kind === StateKind.Value && userEquality(a.value, b.value); +} + const enum StateKind { NoValue, Value, diff --git a/packages/core/rxjs-interop/test/to_signal_spec.ts b/packages/core/rxjs-interop/test/to_signal_spec.ts index 7826436c2b74..5c539c2f9ee5 100644 --- a/packages/core/rxjs-interop/test/to_signal_spec.ts +++ b/packages/core/rxjs-interop/test/to_signal_spec.ts @@ -244,6 +244,38 @@ describe('toSignal()', () => { ); }); + describe('with an equality function', () => { + it( + 'should not update for values considered equal', + test(() => { + const counter$ = new Subject<{value: number}>(); + const counter = toSignal(counter$, { + initialValue: {value: 0}, + equals: (a, b) => a.value === b.value, + }); + + let updates = 0; + const tracker = computed(() => { + updates++; + return counter(); + }); + + expect(tracker()).toEqual({value: 0}); + counter$.next({value: 1}); + expect(tracker()).toEqual({value: 1}); + expect(updates).toBe(2); + + counter$.next({value: 1}); // same value as before + expect(tracker()).toEqual({value: 1}); + expect(updates).toBe(2); // no downstream changes, since value was equal. + + counter$.next({value: 2}); + expect(tracker()).toEqual({value: 2}); + expect(updates).toBe(3); + }), + ); + }); + describe('in a @Component', () => { it('should support `toSignal` as a class member initializer', () => { @Component({