Skip to content

Commit

Permalink
fix(core): add rejectErrors option to toSignal (angular#52474)
Browse files Browse the repository at this point in the history
By default, `toSignal` transforms an `Observable` into a `Signal`, including
the error channel of the Observable. When an error is received, the signal
begins throwing the error.

`toSignal` is intended to serve the same purpose as the `async` pipe, but
the async pipe has a different behavior with errors: it rejects them
outright, throwing them back into RxJS. Rx then propagates the error into
the browser's uncaught error handling logic. In the case of Angular, the
error is then caught by zone.js and reported via the application's
`ErrorHandler`.

This commit introduces a new option for `toSignal` called `rejectErrors`.
With that flag set, `toSignal` copies the async pipe's behavior, allowing
for easier migrations.

Fixes angular#51949

PR Close angular#52474
  • Loading branch information
alxhub authored and tbondwilkinson committed Dec 6, 2023
1 parent 529626d commit 570d7bb
Show file tree
Hide file tree
Showing 4 changed files with 47 additions and 3 deletions.
6 changes: 5 additions & 1 deletion aio/content/guide/rxjs-interop.md
Expand Up @@ -59,10 +59,14 @@ The `manualCleanup` option disables this automatic cleanup. You can use this set

### Error and Completion

If an Observable used in `toSignal` produces an error, that error is thrown when the signal is read.
If an Observable used in `toSignal` produces an error, that error is thrown when the signal is read. It's recommended that errors be handled upstream in the Observable and turned into a value instead (which might indicate to the template that an error page needs to be displayed). This can be done using the `catchError` operator in RxJS.

If an Observable used in `toSignal` completes, the signal continues to return the most recently emitted value before completion.

#### The `rejectErrors` option

`toSignal`'s default behavior for errors propagates the error channel of the `Observable` through to the signal. An alternative approach is to reject errors entirely, using the `rejectErrors` option of `toSignal`. With this option, errors are thrown back into RxJS where they'll be trapped as uncaught exceptions in the global application error handler. Since Observables no longer produce values after they error, the signal returned by `toSignal` will keep returning the last successful value received from the Observable forever. This is the same behavior as the `async` pipe has for errors.

## `toObservable`

The `toObservable` utility creates an `Observable` which tracks the value of a signal. The signal's value is monitored with an `effect`, which emits the value to the Observable when it changes.
Expand Down
1 change: 1 addition & 0 deletions goldens/public-api/core/rxjs-interop/index.md
Expand Up @@ -54,6 +54,7 @@ export interface ToSignalOptions {
initialValue?: unknown;
injector?: Injector;
manualCleanup?: boolean;
rejectErrors?: boolean;
requireSync?: boolean;
}

Expand Down
19 changes: 18 additions & 1 deletion packages/core/rxjs-interop/src/to_signal.ts
Expand Up @@ -48,6 +48,16 @@ export interface ToSignalOptions {
* until the `Observable` itself completes.
*/
manualCleanup?: boolean;

/**
* Whether `toSignal` should throw errors from the Observable error channel back to RxJS, where
* they'll be processed as uncaught exceptions.
*
* In practice, this means that the signal returned by `toSignal` will keep returning the last
* good value forever, as Observables which error produce no further values. This option emulates
* the behavior of the `async` pipe.
*/
rejectErrors?: boolean;
}

// Base case: no options -> `undefined` in the result type.
Expand Down Expand Up @@ -126,7 +136,14 @@ export function toSignal<T, U = undefined>(
// https://github.com/angular/angular/pull/50522.
const sub = source.subscribe({
next: value => state.set({kind: StateKind.Value, value}),
error: error => state.set({kind: StateKind.Error, error}),
error: error => {
if (options?.rejectErrors) {
// Kick the error back to RxJS. It will be caught and rethrown in a macrotask, which causes
// the error to end up as an uncaught exception.
throw error;
}
state.set({kind: StateKind.Error, error});
},
// Completion of the Observable is meaningless to the signal. Signals don't have a concept of
// "complete".
});
Expand Down
24 changes: 23 additions & 1 deletion packages/core/rxjs-interop/test/to_signal_spec.ts
Expand Up @@ -9,7 +9,7 @@
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, Observable, ReplaySubject, Subject} from 'rxjs';
import {BehaviorSubject, Observable, Observer, ReplaySubject, Subject, Subscribable, Unsubscribable} from 'rxjs';

describe('toSignal()', () => {
it('should reflect the last emitted value of an Observable', test(() => {
Expand Down Expand Up @@ -122,6 +122,28 @@ describe('toSignal()', () => {
/toSignal\(\) cannot be called from within a reactive context. Invoking `toSignal` causes new subscriptions every time./);
});

it('should throw the error back to RxJS if rejectErrors is set', () => {
let capturedObserver: Observer<number> = null!;
const fake$ = {
subscribe(observer: Observer<number>): Unsubscribable {
capturedObserver = observer;
return {unsubscribe(): void {}};
},
} as Subscribable<number>;

const s = toSignal(fake$, {initialValue: 0, rejectErrors: true, manualCleanup: true});
expect(s()).toBe(0);
if (capturedObserver === null) {
return fail('Observer not captured as expected.');
}

capturedObserver.next(1);
expect(s()).toBe(1);

expect(() => capturedObserver.error('test')).toThrow('test');
expect(s()).toBe(1);
});

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

0 comments on commit 570d7bb

Please sign in to comment.