From 570d7bbf04b318b7d3c8cd88784f7fc784c8b9a0 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Tue, 31 Oct 2023 13:23:03 -0700 Subject: [PATCH] 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 --- aio/content/guide/rxjs-interop.md | 6 ++++- goldens/public-api/core/rxjs-interop/index.md | 1 + packages/core/rxjs-interop/src/to_signal.ts | 19 ++++++++++++++- .../core/rxjs-interop/test/to_signal_spec.ts | 24 ++++++++++++++++++- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/aio/content/guide/rxjs-interop.md b/aio/content/guide/rxjs-interop.md index 396dcda775ec98..7f14ab76de5726 100644 --- a/aio/content/guide/rxjs-interop.md +++ b/aio/content/guide/rxjs-interop.md @@ -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. diff --git a/goldens/public-api/core/rxjs-interop/index.md b/goldens/public-api/core/rxjs-interop/index.md index d3bc5fc9373db1..57f469b0cd97d9 100644 --- a/goldens/public-api/core/rxjs-interop/index.md +++ b/goldens/public-api/core/rxjs-interop/index.md @@ -54,6 +54,7 @@ export interface ToSignalOptions { initialValue?: unknown; injector?: Injector; manualCleanup?: boolean; + rejectErrors?: boolean; requireSync?: boolean; } diff --git a/packages/core/rxjs-interop/src/to_signal.ts b/packages/core/rxjs-interop/src/to_signal.ts index 7510aa58bbf2a4..c65cf1857be05b 100644 --- a/packages/core/rxjs-interop/src/to_signal.ts +++ b/packages/core/rxjs-interop/src/to_signal.ts @@ -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. @@ -126,7 +136,14 @@ export function toSignal( // 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". }); diff --git a/packages/core/rxjs-interop/test/to_signal_spec.ts b/packages/core/rxjs-interop/test/to_signal_spec.ts index 38496e5a6df3c9..423323f30619fc 100644 --- a/packages/core/rxjs-interop/test/to_signal_spec.ts +++ b/packages/core/rxjs-interop/test/to_signal_spec.ts @@ -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(() => { @@ -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 = null!; + const fake$ = { + subscribe(observer: Observer): Unsubscribable { + capturedObserver = observer; + return {unsubscribe(): void {}}; + }, + } as Subscribable; + + 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();