Skip to content

Commit ced66d4

Browse files
devversionatscott
authored andcommitted
revert: fix(core): allow toSignal in reactive contexts (angular#52049)
Revert (with improvements of): dcf18dc We recently landed a change that allows `toSignal` to be called from within reactive contexts (e.g. `effect`/`computed`). After more thorough investigatio and consideration with the team, we feel like allowing `toSignal` to be called in such contexts is encouraging non-ideal / hard-to-notice code patterns. e.g. a new subscription to an observable is made every time `toSignal` is invoked. There is no caching done here. Additionally, multiple new subscriptions can trigger unintended side-effects- that may slow down the app, result in incorrect/unexpected behavior or perform unnecessary work. Users should instead move the `toSignal` call outside of the `computed` or `effect` and then read the signal values from within their `computed`. e.g. ```ts computed(() => { const smth = toSignal(coldObservable$) return smth() + 2; } ``` --> should instead be: ```ts const smth = toSignal(coldObsverable$); computed(() => smth() + 2); ``` In cases where a new subscription for each invocation is actually intended, a manual subscription can be made. That way it's also much more obvious to users that they are triggering side-effects every time, or causing new subscriptions. PR Close angular#52049
1 parent 4427e1e commit ced66d4

File tree

2 files changed

+59
-24
lines changed

2 files changed

+59
-24
lines changed

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

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {assertInInjectionContext, computed, DestroyRef, inject, Injector, signal, Signal, untracked, WritableSignal, ɵRuntimeError, ɵRuntimeErrorCode} from '@angular/core';
9+
import {assertInInjectionContext, assertNotInReactiveContext, computed, DestroyRef, inject, Injector, signal, Signal, WritableSignal, ɵRuntimeError, ɵRuntimeErrorCode} from '@angular/core';
1010
import {Observable, Subscribable} from 'rxjs';
1111

1212
/**
@@ -159,6 +159,12 @@ export function toSignal<T>(
159159
options: ToSignalOptions<undefined>&{requireSync: true}): Signal<T>;
160160
export function toSignal<T, U = undefined>(
161161
source: Observable<T>|Subscribable<T>, options?: ToSignalOptions<U>): Signal<T|U> {
162+
ngDevMode &&
163+
assertNotInReactiveContext(
164+
toSignal,
165+
'Invoking `toSignal` causes new subscriptions every time. ' +
166+
'Consider moving `toSignal` outside of the reactive context and read the signal value where needed.');
167+
162168
const requiresCleanup = !options?.manualCleanup;
163169
requiresCleanup && !options?.injector && assertInInjectionContext(toSignal);
164170
const cleanupRef =
@@ -175,24 +181,28 @@ export function toSignal<T, U = undefined>(
175181
state = signal<State<T|U>>({kind: StateKind.Value, value: options?.initialValue as U});
176182
}
177183

178-
untracked(() => {
179-
const sub = source.subscribe({
180-
next: value => state.set({kind: StateKind.Value, value}),
181-
error: error => state.set({kind: StateKind.Error, error}),
182-
// Completion of the Observable is meaningless to the signal. Signals don't have a concept of
183-
// "complete".
184-
});
185-
186-
if (ngDevMode && options?.requireSync && state().kind === StateKind.NoValue) {
187-
throw new ɵRuntimeError(
188-
ɵRuntimeErrorCode.REQUIRE_SYNC_WITHOUT_SYNC_EMIT,
189-
'`toSignal()` called with `requireSync` but `Observable` did not emit synchronously.');
190-
}
191-
192-
// Unsubscribe when the current context is destroyed, if requested.
193-
cleanupRef?.onDestroy(sub.unsubscribe.bind(sub));
184+
// Note: This code cannot run inside a reactive context (see assertion above). If we'd support
185+
// this, we would subscribe to the observable outside of the current reactive context, avoiding
186+
// that side-effect signal reads/writes are attribute to the current consumer. The current
187+
// consumer only needs to be notified when the `state` signal changes through the observable
188+
// subscription. Additional context (related to async pipe):
189+
// https://github.com/angular/angular/pull/50522.
190+
const sub = source.subscribe({
191+
next: value => state.set({kind: StateKind.Value, value}),
192+
error: error => state.set({kind: StateKind.Error, error}),
193+
// Completion of the Observable is meaningless to the signal. Signals don't have a concept of
194+
// "complete".
194195
});
195196

197+
if (ngDevMode && options?.requireSync && state().kind === StateKind.NoValue) {
198+
throw new ɵRuntimeError(
199+
ɵRuntimeErrorCode.REQUIRE_SYNC_WITHOUT_SYNC_EMIT,
200+
'`toSignal()` called with `requireSync` but `Observable` did not emit synchronously.');
201+
}
202+
203+
// Unsubscribe when the current context is destroyed, if requested.
204+
cleanupRef?.onDestroy(sub.unsubscribe.bind(sub));
205+
196206
// The actual returned signal is a `computed` of the `State` signal, which maps the various states
197207
// to either values or errors.
198208
return computed(() => {

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

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {computed, EnvironmentInjector, Injector, runInInjectionContext} from '@angular/core';
9+
import {ChangeDetectionStrategy, Component, computed, EnvironmentInjector, Injector, runInInjectionContext} from '@angular/core';
1010
import {toSignal} from '@angular/core/rxjs-interop';
11+
import {TestBed} from '@angular/core/testing';
1112
import {BehaviorSubject, ReplaySubject, Subject} from 'rxjs';
1213

1314
describe('toSignal()', () => {
@@ -109,17 +110,16 @@ describe('toSignal()', () => {
109110
expect(counter()).toBe(1);
110111
});
111112

112-
it('should allow toSignal creation in a reactive context - issue 51027', () => {
113+
it('should not allow toSignal creation in a reactive context', () => {
113114
const counter$ = new BehaviorSubject(1);
114-
115-
const injector = Injector.create([]);
116-
117115
const doubleCounter = computed(() => {
118-
const counter = toSignal(counter$, {requireSync: true, injector});
116+
const counter = toSignal(counter$, {requireSync: true});
119117
return counter() * 2;
120118
});
121119

122-
expect(doubleCounter()).toBe(2);
120+
expect(() => doubleCounter())
121+
.toThrowError(
122+
/toSignal\(\) cannot be called from within a reactive context. Invoking `toSignal` causes new subscriptions every time./);
123123
});
124124

125125
describe('with no initial value', () => {
@@ -173,6 +173,31 @@ describe('toSignal()', () => {
173173
expect(counter()).not.toBeNull();
174174
}));
175175
});
176+
177+
describe('in a @Component', () => {
178+
it('should support `toSignal` as a class member initializer', () => {
179+
@Component({
180+
template: '{{counter()}}',
181+
changeDetection: ChangeDetectionStrategy.OnPush,
182+
})
183+
class TestCmp {
184+
// Component creation should not run inside the template effect/consumer,
185+
// hence using `toSignal` should be allowed/supported.
186+
counter$ = new Subject<number>();
187+
counter = toSignal(this.counter$);
188+
}
189+
190+
const fixture = TestBed.createComponent(TestCmp);
191+
fixture.detectChanges();
192+
193+
expect(fixture.nativeElement.textContent).toBe('');
194+
195+
fixture.componentInstance.counter$.next(2);
196+
fixture.detectChanges();
197+
198+
expect(fixture.nativeElement.textContent).toBe('2');
199+
});
200+
});
176201
});
177202

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

0 commit comments

Comments
 (0)