Skip to content

Commit

Permalink
fix(core): adjust toSignal types to handle more common cases
Browse files Browse the repository at this point in the history
This commit cleans up the signatures of `toSignal` to better handle the
types of situations that it might be used in, and produce better type
inference results.

Fixes #50687
Fixes #50591

Co-authored-by: Andrew Scott <atscott@google.com>
  • Loading branch information
alxhub and atscott committed Oct 9, 2023
1 parent 7bfe207 commit 84248fa
Show file tree
Hide file tree
Showing 2 changed files with 60 additions and 83 deletions.
99 changes: 18 additions & 81 deletions packages/core/rxjs-interop/src/to_signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ import {Observable, Subscribable} from 'rxjs';
*
* @publicApi
*/
export interface ToSignalOptions<T> {
export interface ToSignalOptions {
/**
* Initial value for the signal produced by `toSignal`.
*
* This will be the value of the signal until the observable emits its first value.
*/
initialValue?: T;
initialValue?: unknown;

/**
* Whether to require that the observable emits synchronously when `toSignal` subscribes.
Expand Down Expand Up @@ -50,85 +50,24 @@ export interface ToSignalOptions<T> {
manualCleanup?: boolean;
}

/**
* Get the current value of an `Observable` as a reactive `Signal`.
*
* `toSignal` returns a `Signal` which provides synchronous reactive access to values produced
* by the given `Observable`, by subscribing to that `Observable`. The returned `Signal` will always
* have the most recent value emitted by the subscription, and will throw an error if the
* `Observable` errors.
*
* Before the `Observable` emits its first value, the `Signal` will return `undefined`. To avoid
* this, either an `initialValue` can be passed or the `requireSync` option enabled.
*
* By default, the subscription will be automatically cleaned up when the current [injection
* context](guide/dependency-injection-context) is destroyed. For example, when `toObservable` is
* called during the construction of a component, the subscription will be cleaned up when the
* component is destroyed. If an [injection context](/guide/dependency-injection-context) is not
* available, an explicit `Injector` can be passed instead.
*
* If the subscription should persist until the `Observable` itself completes, the `manualCleanup`
* option can be specified instead, which disables the automatic subscription teardown. No injection
* context is needed in this configuration as well.
*/
// Base case: no options -> `undefined` in the result type.
export function toSignal<T>(source: Observable<T>|Subscribable<T>): Signal<T|undefined>;

/**
* Get the current value of an `Observable` as a reactive `Signal`.
*
* `toSignal` returns a `Signal` which provides synchronous reactive access to values produced
* by the given `Observable`, by subscribing to that `Observable`. The returned `Signal` will always
* have the most recent value emitted by the subscription, and will throw an error if the
* `Observable` errors.
*
* Before the `Observable` emits its first value, the `Signal` will return the configured
* `initialValue`, or `undefined` if no `initialValue` is provided. If the `Observable` is
* guaranteed to emit synchronously, then the `requireSync` option can be passed instead.
*
* By default, the subscription will be automatically cleaned up when the current [injection
* context](/guide/dependency-injection-context) is destroyed. For example, when `toObservable` is
* called during the construction of a component, the subscription will be cleaned up when the
* component is destroyed. If an injection context is not available, an explicit `Injector` can be
* passed instead.
*
* If the subscription should persist until the `Observable` itself completes, the `manualCleanup`
* option can be specified instead, which disables the automatic subscription teardown. No injection
* context is needed in this configuration as well.
*
* @developerPreview
*/
// Options with `undefined` initial value and no `requiredSync` -> `undefined`.
export function toSignal<T>(
source: Observable<T>|Subscribable<T>,
options?: ToSignalOptions<undefined>&{requireSync?: false}): Signal<T|undefined>;


/**
* Get the current value of an `Observable` as a reactive `Signal`.
*
* `toSignal` returns a `Signal` which provides synchronous reactive access to values produced
* by the given `Observable`, by subscribing to that `Observable`. The returned `Signal` will always
* have the most recent value emitted by the subscription, and will throw an error if the
* `Observable` errors.
*
* Before the `Observable` emits its first value, the `Signal` will return the configured
* `initialValue`. If the `Observable` is guaranteed to emit synchronously, then the `requireSync`
* option can be passed instead.
*
* By default, the subscription will be automatically cleaned up when the current [injection
* context](guide/dependency-injection-context) is destroyed. For example, when `toObservable` is
* called during the construction of a component, the subscription will be cleaned up when the
* component is destroyed. If an [injection context](/guide/dependency-injection-context) is not
* available, an explicit `Injector` can be passed instead.
*
* If the subscription should persist until the `Observable` itself completes, the `manualCleanup`
* option can be specified instead, which disables the automatic subscription teardown. No injection
* context is needed in this configuration as well.
*
* @developerPreview
*/
export function toSignal<T, U extends T|null|undefined>(
options: ToSignalOptions&{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}): Signal<T|null>;
// Options with `undefined` initial value and `requiredSync` -> strict result type.
export function toSignal<T>(
source: Observable<T>|Subscribable<T>,
options: ToSignalOptions<U>&{initialValue: U, requireSync?: false}): Signal<T|U>;
options: ToSignalOptions&{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}): Signal<T|U>;

/**
* Get the current value of an `Observable` as a reactive `Signal`.
Expand All @@ -154,11 +93,9 @@ export function toSignal<T, U extends T|null|undefined>(
*
* @developerPreview
*/
export function toSignal<T>(
source: Observable<T>|Subscribable<T>,
options: ToSignalOptions<undefined>&{requireSync: true}): Signal<T>;
export function toSignal<T, U = undefined>(
source: Observable<T>|Subscribable<T>, options?: ToSignalOptions<U>): Signal<T|U> {
source: Observable<T>|Subscribable<T>,
options?: ToSignalOptions&{initialValue?: U}): Signal<T|U> {
ngDevMode &&
assertNotInReactiveContext(
toSignal,
Expand Down
44 changes: 42 additions & 2 deletions packages/core/rxjs-interop/test/to_signal_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ChangeDetectionStrategy, Component, computed, EnvironmentInjector, Injector, runInInjectionContext} from '@angular/core';
import {ChangeDetectionStrategy, Component, computed, EnvironmentInjector, Injector, runInInjectionContext, Signal} from '@angular/core';
import {toSignal} from '@angular/core/rxjs-interop';
import {TestBed} from '@angular/core/testing';
import {BehaviorSubject, ReplaySubject, Subject} from 'rxjs';
import {BehaviorSubject, Observable, ReplaySubject, Subject} from 'rxjs';

describe('toSignal()', () => {
it('should reflect the last emitted value of an Observable', test(() => {
Expand Down Expand Up @@ -198,6 +198,46 @@ describe('toSignal()', () => {
expect(fixture.nativeElement.textContent).toBe('2');
});
});

describe('type tests', () => {
const src = new Subject<any>();
it('should allow empty array initial values', test(() => {
const res: Signal<string[]> = toSignal(src as Observable<string[]>, {initialValue: []});
expect(res).toBeDefined();
}));

it('should allow literal types', test(() => {
type Animal = 'cat'|'dog';
const res: Signal<Animal> = toSignal(src as Observable<Animal>, {initialValue: 'cat'});
expect(res).toBeDefined();
}));

it('should not allow initial values outside of the observable type', test(() => {
type Animal = 'cat'|'dog';
// @ts-expect-error
const res = toSignal(src as Observable<Animal>, {initialValue: 'cow'});
expect(res).toBeDefined();
}));

it('allows null as an initial value', test(() => {
const res = toSignal(src as Observable<string>, {initialValue: null});
const res2: Signal<string|null> = res;
// @ts-expect-error
const res3: Signal<string|undefined> = res;
expect(res2).toBeDefined();
expect(res3).toBeDefined();
}));


it('allows undefined as an initial value', test(() => {
const res = toSignal(src as Observable<string>, {initialValue: undefined});
const res2: Signal<string|undefined> = res;
// @ts-expect-error
const res3: Signal<string|null> = res;
expect(res2).toBeDefined();
expect(res3).toBeDefined();
}));
});
});

function test(fn: () => void|Promise<void>): () => Promise<void> {
Expand Down

0 comments on commit 84248fa

Please sign in to comment.