Skip to content

Commit 7bb3ffb

Browse files
committed
fix(core): add rejectErrors option to toSignal (#52474)
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 #51949 PR Close #52474
1 parent 8ef4b1d commit 7bb3ffb

File tree

4 files changed

+47
-3
lines changed

4 files changed

+47
-3
lines changed

aio/content/guide/rxjs-interop.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,14 @@ The `manualCleanup` option disables this automatic cleanup. You can use this set
5959

6060
### Error and Completion
6161

62-
If an Observable used in `toSignal` produces an error, that error is thrown when the signal is read.
62+
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.
6363

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

66+
#### The `rejectErrors` option
67+
68+
`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.
69+
6670
## `toObservable`
6771

6872
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.

goldens/public-api/core/rxjs-interop/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface ToSignalOptions {
5454
initialValue?: unknown;
5555
injector?: Injector;
5656
manualCleanup?: boolean;
57+
rejectErrors?: boolean;
5758
requireSync?: boolean;
5859
}
5960

packages/core/rxjs-interop/src/to_signal.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ export interface ToSignalOptions {
4848
* until the `Observable` itself completes.
4949
*/
5050
manualCleanup?: boolean;
51+
52+
/**
53+
* Whether `toSignal` should throw errors from the Observable error channel back to RxJS, where
54+
* they'll be processed as uncaught exceptions.
55+
*
56+
* In practice, this means that the signal returned by `toSignal` will keep returning the last
57+
* good value forever, as Observables which error produce no further values. This option emulates
58+
* the behavior of the `async` pipe.
59+
*/
60+
rejectErrors?: boolean;
5161
}
5262

5363
// Base case: no options -> `undefined` in the result type.
@@ -126,7 +136,14 @@ export function toSignal<T, U = undefined>(
126136
// https://github.com/angular/angular/pull/50522.
127137
const sub = source.subscribe({
128138
next: value => state.set({kind: StateKind.Value, value}),
129-
error: error => state.set({kind: StateKind.Error, error}),
139+
error: error => {
140+
if (options?.rejectErrors) {
141+
// Kick the error back to RxJS. It will be caught and rethrown in a macrotask, which causes
142+
// the error to end up as an uncaught exception.
143+
throw error;
144+
}
145+
state.set({kind: StateKind.Error, error});
146+
},
130147
// Completion of the Observable is meaningless to the signal. Signals don't have a concept of
131148
// "complete".
132149
});

packages/core/rxjs-interop/test/to_signal_spec.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import {ChangeDetectionStrategy, Component, computed, EnvironmentInjector, Injector, runInInjectionContext, Signal} from '@angular/core';
1010
import {toSignal} from '@angular/core/rxjs-interop';
1111
import {TestBed} from '@angular/core/testing';
12-
import {BehaviorSubject, Observable, ReplaySubject, Subject} from 'rxjs';
12+
import {BehaviorSubject, Observable, Observer, ReplaySubject, Subject, Subscribable, Unsubscribable} from 'rxjs';
1313

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

125+
it('should throw the error back to RxJS if rejectErrors is set', () => {
126+
let capturedObserver: Observer<number> = null!;
127+
const fake$ = {
128+
subscribe(observer: Observer<number>): Unsubscribable {
129+
capturedObserver = observer;
130+
return {unsubscribe(): void {}};
131+
},
132+
} as Subscribable<number>;
133+
134+
const s = toSignal(fake$, {initialValue: 0, rejectErrors: true, manualCleanup: true});
135+
expect(s()).toBe(0);
136+
if (capturedObserver === null) {
137+
return fail('Observer not captured as expected.');
138+
}
139+
140+
capturedObserver.next(1);
141+
expect(s()).toBe(1);
142+
143+
expect(() => capturedObserver.error('test')).toThrow('test');
144+
expect(s()).toBe(1);
145+
});
146+
125147
describe('with no initial value', () => {
126148
it('should return `undefined` if read before a value is emitted', test(() => {
127149
const counter$ = new Subject<number>();

0 commit comments

Comments
 (0)