Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions goldens/public-api/core/rxjs-interop/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(observable: Observable<T>, opts?: OutputOptions): OutputRef<T>;
Expand All @@ -34,31 +35,32 @@ export interface ToObservableOptions {
export function toSignal<T>(source: Observable<T> | Subscribable<T>): Signal<T | undefined>;

// @public (undocumented)
export function toSignal<T>(source: Observable<T> | Subscribable<T>, options: ToSignalOptions & {
export function toSignal<T>(source: Observable<T> | Subscribable<T>, options: NoInfer<ToSignalOptions<T | undefined>> & {
initialValue?: undefined;
requireSync?: false;
}): Signal<T | undefined>;

// @public (undocumented)
export function toSignal<T>(source: Observable<T> | Subscribable<T>, options: ToSignalOptions & {
export function toSignal<T>(source: Observable<T> | Subscribable<T>, options: NoInfer<ToSignalOptions<T | null>> & {
initialValue?: null;
requireSync?: false;
}): Signal<T | null>;

// @public (undocumented)
export function toSignal<T>(source: Observable<T> | Subscribable<T>, options: ToSignalOptions & {
export function toSignal<T>(source: Observable<T> | Subscribable<T>, options: NoInfer<ToSignalOptions<T>> & {
initialValue?: undefined;
requireSync: true;
}): Signal<T>;

// @public (undocumented)
export function toSignal<T, const U extends T>(source: Observable<T> | Subscribable<T>, options: ToSignalOptions & {
export function toSignal<T, const U extends T>(source: Observable<T> | Subscribable<T>, options: NoInfer<ToSignalOptions<T | U>> & {
initialValue: U;
requireSync?: false;
}): Signal<T | U>;

// @public
export interface ToSignalOptions {
export interface ToSignalOptions<T> {
equals?: ValueEqualityFn<T>;
initialValue?: unknown;
injector?: Injector;
manualCleanup?: boolean;
Expand Down
39 changes: 31 additions & 8 deletions packages/core/rxjs-interop/src/to_signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ import {
ɵRuntimeError,
ɵRuntimeErrorCode,
} from '@angular/core';
import {ValueEqualityFn} from '@angular/core/primitives/signals';
import {Observable, Subscribable} from 'rxjs';

/**
* Options for `toSignal`.
*
* @publicApi
*/
export interface ToSignalOptions {
export interface ToSignalOptions<T> {
/**
* Initial value for the signal produced by `toSignal`.
*
Expand Down Expand Up @@ -70,29 +71,39 @@ 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<T>;
}

// Base case: no options -> `undefined` in the result type.
export function toSignal<T>(source: Observable<T> | Subscribable<T>): Signal<T | undefined>;
// Options with `undefined` initial value and no `requiredSync` -> `undefined`.
export function toSignal<T>(
source: Observable<T> | Subscribable<T>,
options: ToSignalOptions & {initialValue?: undefined; requireSync?: false},
options: NoInfer<ToSignalOptions<T | undefined>> & {
initialValue?: undefined;
requireSync?: false;
},
): Signal<T | undefined>;
// Options with `null` initial value -> `null`.
export function toSignal<T>(
source: Observable<T> | Subscribable<T>,
options: ToSignalOptions & {initialValue?: null; requireSync?: false},
options: NoInfer<ToSignalOptions<T | null>> & {initialValue?: null; requireSync?: false},
): Signal<T | null>;
// Options with `undefined` initial value and `requiredSync` -> strict result type.
export function toSignal<T>(
source: Observable<T> | Subscribable<T>,
options: ToSignalOptions & {initialValue?: undefined; requireSync: true},
options: NoInfer<ToSignalOptions<T>> & {initialValue?: undefined; requireSync: true},
): Signal<T>;
// Options with a more specific initial value type.
export function toSignal<T, const U extends T>(
source: Observable<T> | Subscribable<T>,
options: ToSignalOptions & {initialValue: U; requireSync?: false},
options: NoInfer<ToSignalOptions<T | U>> & {initialValue: U; requireSync?: false},
): Signal<T | U>;

/**
Expand Down Expand Up @@ -121,7 +132,7 @@ export function toSignal<T, const U extends T>(
*/
export function toSignal<T, U = undefined>(
source: Observable<T> | Subscribable<T>,
options?: ToSignalOptions & {initialValue?: U},
options?: ToSignalOptions<T | U> & {initialValue?: U},
): Signal<T | U> {
ngDevMode &&
assertNotInReactiveContext(
Expand All @@ -136,15 +147,20 @@ export function toSignal<T, U = undefined>(
? 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<State<T | U>>;
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<State<T | U>>({kind: StateKind.Value, value: options?.initialValue as U});
state = signal<State<T | U>>(
{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
Expand Down Expand Up @@ -197,6 +213,13 @@ export function toSignal<T, U = undefined>(
});
}

function makeToSignalEquals<T>(
userEquality: ValueEqualityFn<T> = Object.is,
): ValueEqualityFn<State<T>> {
return (a, b) =>
a.kind === StateKind.Value && b.kind === StateKind.Value && userEquality(a.value, b.value);
}

const enum StateKind {
NoValue,
Value,
Expand Down
32 changes: 32 additions & 0 deletions packages/core/rxjs-interop/test/to_signal_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down