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
2 changes: 2 additions & 0 deletions goldens/public-api/core/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
RENDERER_NOT_FOUND = 407,
// (undocumented)
REQUIRE_SYNC_WITHOUT_SYNC_EMIT = 601,
// (undocumented)
SIGNAL_WRITE_FROM_ILLEGAL_CONTEXT = 600,
// (undocumented)
TEMPLATE_STRUCTURE_ERROR = 305,
Expand Down
22 changes: 15 additions & 7 deletions goldens/public-api/core/rxjs-interop/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,29 @@ import { Observable } from 'rxjs';
import { Signal } from '@angular/core';

// @public
export function fromObservable<T>(source: Observable<T>): Signal<T>;

// @public
export function fromObservable<T, U extends T | null | undefined>(source: Observable<T>, initialValue: U): Signal<T | U>;
export function takeUntilDestroyed<T>(destroyRef?: DestroyRef): MonoTypeOperatorFunction<T>;

// @public
export function fromSignal<T>(source: Signal<T>, options?: FromSignalOptions): Observable<T>;
export function toObservable<T>(source: Signal<T>, options?: toObservableOptions): Observable<T>;

// @public
export interface FromSignalOptions {
export interface toObservableOptions {
injector?: Injector;
}

// @public
export function takeUntilDestroyed<T>(destroyRef?: DestroyRef): MonoTypeOperatorFunction<T>;
export function toSignal<T>(source: Observable<T>): Signal<T | undefined>;

// @public
export function toSignal<T, U extends T | null | undefined>(source: Observable<T>, options: {
initialValue: U;
requireSync?: false;
}): Signal<T | U>;

// @public (undocumented)
export function toSignal<T>(source: Observable<T>, options: {
requireSync: true;
}): Signal<T>;

// (No @packageDocumentation comment for this package)

Expand Down
4 changes: 2 additions & 2 deletions packages/core/rxjs-interop/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
* found in the LICENSE file at https://angular.io/license
*/

export {fromObservable} from './from_observable';
export {fromSignal, FromSignalOptions} from './from_signal';
export {takeUntilDestroyed} from './take_until_destroyed';
export {toObservable, toObservableOptions} from './to_observable';
export {toSignal} from './to_signal';
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import {assertInInjectionContext, effect, inject, Injector, Signal} from '@angul
import {Observable} from 'rxjs';

/**
* Options for `fromSignal`.
* Options for `toObservable`.
*
* @developerPreview
*/
export interface FromSignalOptions {
export interface toObservableOptions {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pascal

/**
* The `Injector` to use when creating the effect.
*
Expand All @@ -28,27 +28,30 @@ export interface FromSignalOptions {
*
* The signal's value will be propagated into the `Observable`'s subscribers using an `effect`.
*
* `fromSignal` must be called in an injection context.
* `toObservable` must be called in an injection context.
*
* @developerPreview
*/
export function fromSignal<T>(
export function toObservable<T>(
source: Signal<T>,
options?: FromSignalOptions,
options?: toObservableOptions,
): Observable<T> {
!options?.injector && assertInInjectionContext(fromSignal);
!options?.injector && assertInInjectionContext(toObservable);
const injector = options?.injector ?? inject(Injector);

// Creating a new `Observable` allows the creation of the effect to be lazy. This allows for all
// references to `source` to be dropped if the `Observable` is fully unsubscribed and thrown away.
return new Observable(observer => {
const watcher = effect(() => {
let value: T;
try {
observer.next(source());
value = source();
} catch (err) {
observer.error(err);
return;
}
}, {injector, manualCleanup: true});
observer.next(value);
}, {injector, manualCleanup: true, allowSignalWrites: true});
return () => watcher.destroy();
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,66 +7,73 @@
*/

import {assertInInjectionContext, computed, DestroyRef, inject, signal, Signal, WritableSignal} from '@angular/core';
import {RuntimeError, RuntimeErrorCode} from '@angular/core/src/errors';
import {Observable} from 'rxjs';

import {untracked} from '../../src/signals';

/**
* Get the current value of an `Observable` as a reactive `Signal`.
*
* `fromObservable` returns a `Signal` which provides synchronous reactive access to values produced
* `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.
*
* The subscription will last for the lifetime of the current injection context. That is, if
* `fromObservable` is called from a component context, the subscription will be cleaned up when the
* `toSignal` is called from a component context, the subscription will be cleaned up when the
* component is destroyed. When called outside of a component, the current `EnvironmentInjector`'s
* lifetime will be used (which is typically the lifetime of the application itself).
*
* If the `Observable` does not produce a value before the `Signal` is read, the `Signal` will throw
* an error. To avoid this, use a synchronous `Observable` (potentially created with the `startWith`
* operator) or pass an initial value to `fromObservable` as the second argument.
* operator) or pass an initial value to `toSignal` as the second argument.
*
* `fromObservable` must be called in an injection context.
* `toSignal` must be called in an injection context.
*/
export function fromObservable<T>(source: Observable<T>): Signal<T>;
export function toSignal<T>(source: Observable<T>): Signal<T|undefined>;

/**
* Get the current value of an `Observable` as a reactive `Signal`.
*
* `fromObservable` returns a `Signal` which provides synchronous reactive access to values produced
* `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.
*
* The subscription will last for the lifetime of the current injection context. That is, if
* `fromObservable` is called from a component context, the subscription will be cleaned up when the
* `toSignal` is called from a component context, the subscription will be cleaned up when the
* component is destroyed. When called outside of a component, the current `EnvironmentInjector`'s
* lifetime will be used (which is typically the lifetime of the application itself).
*
* Before the `Observable` emits its first value, the `Signal` will return the configured
* `initialValue`. If the `Observable` is known to produce a value before the `Signal` will be read,
* `initialValue` does not need to be passed.
*
* `fromObservable` must be called in an injection context.
* `toSignal` must be called in an injection context.
*
* @developerPreview
*/
export function fromObservable<T, U extends T|null|undefined>(
// fromObservable(Observable<Animal>) -> Signal<Cat>
source: Observable<T>, initialValue: U): Signal<T|U>;
export function fromObservable<T, U = never>(source: Observable<T>, initialValue?: U): Signal<T|U> {
assertInInjectionContext(fromObservable);
export function toSignal<T, U extends T|null|undefined>(
// toSignal(Observable<Animal>, {initialValue: null}) -> Signal<Animal|null>
source: Observable<T>, options: {initialValue: U, requireSync?: false}): Signal<T|U>;
export function toSignal<T>(
// toSignal(Observable<Animal>, {requireSync: true}) -> Signal<Animal>
source: Observable<T>, options: {requireSync: true}): Signal<T>;
// toSignal(Observable<Animal>) -> Signal<Animal|undefined>
export function toSignal<T, U = undefined>(
source: Observable<T>, options?: {initialValue?: U, requireSync?: boolean}): Signal<T|U> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd add an explanatory comment that this signature has to be listed second

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't though, does it? The first two can be in either order. Or do you mean the last one has to be listed third?

assertInInjectionContext(toSignal);

// 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 (initialValue === undefined && arguments.length !== 2) {
// No initial value was passed, so initially the signal is in a `NoValue` state and will throw
// if accessed.
if (options?.requireSync) {
// Initially the signal is in a `NoValue` state.
state = signal({kind: StateKind.NoValue});
} else {
// An initial value was passed, so use it.
state = signal<State<T|U>>({kind: StateKind.Value, value: initialValue!});
// 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});
}

const sub = source.subscribe({
Expand All @@ -76,6 +83,12 @@ export function fromObservable<T, U = never>(source: Observable<T>, initialValue
// "complete".
});

if (ngDevMode && options?.requireSync && untracked(state).kind === StateKind.NoValue) {
throw new RuntimeError(
RuntimeErrorCode.REQUIRE_SYNC_WITHOUT_SYNC_EMIT,
'`toSignal()` called with `requireSync` but `Observable` did not emit synchronously.');
}

// Unsubscribe when the current context is destroyed.
inject(DestroyRef).onDestroy(sub.unsubscribe.bind(sub));

Expand All @@ -89,8 +102,11 @@ export function fromObservable<T, U = never>(source: Observable<T>, initialValue
case StateKind.Error:
throw current.error;
case StateKind.NoValue:
// This shouldn't really happen because the error is thrown on creation.
// TODO(alxhub): use a RuntimeError when we finalize the error semantics
throw new Error(`fromObservable() signal read before the Observable emitted`);
throw new RuntimeError(
RuntimeErrorCode.REQUIRE_SYNC_WITHOUT_SYNC_EMIT,
'`toSignal()` called with `requireSync` but `Observable` did not emit synchronously.');
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
*/

import {Component, computed, Injector, signal} from '@angular/core';
import {fromSignal} from '@angular/core/rxjs-interop';
import {toObservable} from '@angular/core/rxjs-interop';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {take, toArray} from 'rxjs/operators';

describe('fromSignal()', () => {
describe('toObservable()', () => {
let fixture!: ComponentFixture<unknown>;
let injector!: Injector;

Expand All @@ -33,7 +33,7 @@ describe('fromSignal()', () => {

it('should produce an observable that tracks a signal', async () => {
const counter = signal(0);
const counterValues = fromSignal(counter, {injector}).pipe(take(3), toArray()).toPromise();
const counterValues = toObservable(counter, {injector}).pipe(take(3), toArray()).toPromise();

// Initial effect execution, emits 0.
flushEffects();
Expand Down Expand Up @@ -61,7 +61,7 @@ describe('fromSignal()', () => {
}
});

const counter$ = fromSignal(counter, {injector});
const counter$ = toObservable(counter, {injector});

let currentValue: number = 0;
let currentError: any = null;
Expand All @@ -88,7 +88,7 @@ describe('fromSignal()', () => {
return 0;
});

fromSignal(counter, {injector});
toObservable(counter, {injector});

// Simply creating the Observable shouldn't trigger a signal read.
expect(counterRead).toBeFalse();
Expand All @@ -108,7 +108,7 @@ describe('fromSignal()', () => {
return counter();
});

const counter$ = fromSignal(trackedCounter, {injector});
const counter$ = toObservable(trackedCounter, {injector});

const sub = counter$.subscribe();
expect(readCount).toBe(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
*/

import {EnvironmentInjector, Injector, runInInjectionContext} from '@angular/core';
import {fromObservable} from '@angular/core/rxjs-interop';
import {BehaviorSubject, Subject} from 'rxjs';
import {toSignal} from '@angular/core/rxjs-interop';
import {BehaviorSubject, ReplaySubject, Subject} from 'rxjs';

describe('fromObservable()', () => {
describe('toSignal()', () => {
it('should reflect the last emitted value of an Observable', test(() => {
const counter$ = new BehaviorSubject(0);
const counter = fromObservable(counter$);
const counter = toSignal(counter$);

expect(counter()).toBe(0);
counter$.next(1);
Expand All @@ -25,7 +25,7 @@ describe('fromObservable()', () => {
it('should notify when the last emitted value of an Observable changes', test(() => {
let seenValue: number = 0;
const counter$ = new BehaviorSubject(1);
const counter = fromObservable(counter$);
const counter = toSignal(counter$);

expect(counter()).toBe(1);

Expand All @@ -35,7 +35,7 @@ describe('fromObservable()', () => {

it('should propagate an error returned by the Observable', test(() => {
const counter$ = new BehaviorSubject(1);
const counter = fromObservable(counter$);
const counter = toSignal(counter$);

expect(counter()).toBe(1);

Expand All @@ -46,7 +46,7 @@ describe('fromObservable()', () => {
it('should unsubscribe when the current context is destroyed', test(() => {
const counter$ = new BehaviorSubject(0);
const injector = Injector.create({providers: []}) as EnvironmentInjector;
const counter = runInInjectionContext(injector, () => fromObservable(counter$));
const counter = runInInjectionContext(injector, () => toSignal(counter$));

expect(counter()).toBe(0);
counter$.next(1);
Expand All @@ -64,28 +64,42 @@ describe('fromObservable()', () => {
}));

describe('with no initial value', () => {
it('should throw if called before a value is emitted', test(() => {
it('should return `undefined` if read before a value is emitted', test(() => {
const counter$ = new Subject<number>();
const counter = fromObservable(counter$);
const counter = toSignal(counter$);

expect(() => counter()).toThrow();
expect(counter()).toBeUndefined();
counter$.next(1);
expect(counter()).toBe(1);
}));

it('should not throw if a value is emitted before called', test(() => {
const counter$ = new Subject<number>();
const counter = fromObservable(counter$);
const counter = toSignal(counter$);

counter$.next(1);
expect(() => counter()).not.toThrow();
}));
});

describe('with requireSync', () => {
it('should throw if created before a value is emitted', test(() => {
const counter$ = new Subject<number>();
expect(() => toSignal(counter$, {requireSync: true})).toThrow();
}));

it('should not throw if a value emits synchronously on creation', test(() => {
const counter$ = new ReplaySubject<number>(1);
counter$.next(1);
const counter = toSignal(counter$);
expect(counter()).toBe(1);
}));
});

describe('with an initial value', () => {
it('should return the initial value if called before a value is emitted', test(() => {
const counter$ = new Subject<number>();
const counter = fromObservable(counter$, null);
const counter = toSignal(counter$, {initialValue: null});

expect(counter()).toBeNull();
counter$.next(1);
Expand All @@ -94,7 +108,7 @@ describe('fromObservable()', () => {

it('should not return the initial value if called after a value is emitted', test(() => {
const counter$ = new Subject<number>();
const counter = fromObservable(counter$, null);
const counter = toSignal(counter$, {initialValue: null});

counter$.next(1);
expect(counter()).not.toBeNull();
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const enum RuntimeErrorCode {

// Signal Errors
SIGNAL_WRITE_FROM_ILLEGAL_CONTEXT = 600,
REQUIRE_SYNC_WITHOUT_SYNC_EMIT = 601,

// Styling Errors

Expand Down