
signal() Is Also a Function: the Bug That Silenced Every rx-state-bridge Operator Under Angular
rx-state-bridge is a library I wrote to solve a tedious, repetitive problem: every fetch in a UI ends up with the same loading/error/data trio rewritten by hand, subscription after subscription. The idea was simple β RxJS operators that write that state for you, whether the "state" is a React useState or an Angular signal(). I shipped 1.0, the tests passed, the README had a side-by-side example for both frameworks. Nice.
The first serious issue landed a few days later, and the cause was a line I'd stared at dozens of times without seeing the problem.
The bug: every operator became a silent no-op under Angular
Every operator in the library (withLoading, catchToState, bindTo, the rest) ends by writing to a state "indicator." To accept both useState (a function) and an Angular signal (an object with .set()), there's an internal normalizer, applyIndicator, that collapses the two shapes into one call. The first version did the obvious thing: check if it's a function, call it directly; otherwise, assume it has .set() and call that.
// the buggy version
function applyIndicator<T>(indicator: StateIndicator<T>, value: T): void {
if (typeof indicator === 'function') {
(indicator as (value: T) => void)(value);
return;
}
(indicator as { set: (value: T) => void }).set(value);
}
Looks correct. It passes every test written against React's useState, because useState's setter really is just a function β no .set on it at all.
The problem is Angular's WritableSignal. A signal isn't just an object with .set() β it's also callable: mySignal() is how you read its current value. So typeof indicator === 'function' was true for a signal too, and the if grabbed the wrong branch first. applyIndicator(mySignal, newValue) effectively became mySignal(newValue) β which isn't a setter, it's the getter being invoked with an argument it completely ignores. No error, no warning. The value just never changed.
And the worst part: it was exactly the pattern from my own Angular example in the README.
readonly loading = signal(false);
readonly data$ = source$.pipe(withSmoothLoading(this.loading, 500));
Running that, this.loading never left false. Not because withSmoothLoading was broken β because the function meant to write into the signal was, without telling anyone, reading it instead.
The fix: flip the order of the check
The fix is to check .set first, not typeof. A WritableSignal has .set as a property; a plain callback ((value) => void) doesn't. Checking .set first routes both signal shapes (a literal { set } object and the callable WritableSignal) down the same path, leaving only a genuinely "dumb" function β one with no .set β to fall through to the other branch.
function applyIndicator<T>(indicator: StateIndicator<T>, value: T): void {
if (typeof (indicator as { set?: unknown }).set === 'function') {
(indicator as { set: (value: T) => void }).set(value);
return;
}
(indicator as (value: T) => void)(value);
}
One line moved, but the effect was that every operator in the library β all six of them β silently did nothing under Angular until this patch. It's the kind of bug only a test written specifically against a real signal() from @angular/core (not a { set: vi.fn() } mock) could catch, because a mock never reproduces the "also callable" part that makes this treacherous.
The second bug: a timer that outlived its own unsubscribe
While chasing that one, I found another bug in the same neighborhood of code: withSmoothLoading. Its promise is to hold the loading indicator at true for at least minDuration ms, even if the response arrives faster, to avoid the flicker of a spinner that appears and vanishes in 50ms.
The original implementation scheduled that delay with a standalone timer(...).subscribe(...), with no link back to the outer subscription:
// the buggy version β the timer has no idea the operator was cancelled
const remaining = minDuration - (Date.now() - startedAt);
timer(remaining).subscribe(() => {
applyIndicator(indicator, false);
emit();
});
It worked in the obvious cases. The problem shows up when someone unsubscribes during that waiting window β a component unmounting, a switchMap moving on to a new request mid-flight. unsubscribe() kills the outer subscription, but the inner timer is its own, disconnected subscription β it has no idea it should stop. It keeps counting, and when it fires, it writes false into an indicator that now belongs to another request, or doesn't exist anymore.
The fix: tie the timer to the Observable's own teardown
The fix wasn't "make the timer cancellable" in isolation β it was moving the wait inside the teardown function of the manually constructed Observable, which RxJS already calls automatically on any unsubscribe:
return new Observable<T>((subscriber) => {
let graceTimer: ReturnType<typeof setTimeout> | undefined;
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
if (graceTimer !== undefined) clearTimeout(graceTimer);
applyIndicator(indicator, false);
};
const settle = (emit: () => void) => {
const remaining = minDuration - (Date.now() - startedAt);
if (remaining <= 0) {
finish();
emit();
return;
}
graceTimer = setTimeout(() => {
graceTimer = undefined;
finish();
emit();
}, remaining);
};
const sourceSubscription = source.subscribe({
next: (value) => subscriber.next(value),
error: (err) => settle(() => subscriber.error(err)),
complete: () => settle(() => subscriber.complete()),
});
return () => {
sourceSubscription.unsubscribe();
finish(); // clears the pending graceTimer, if any
};
});
Now an unsubscribe() at any point β including mid-way through the grace window itself β cancels the pending setTimeout and resets the indicator immediately, because finish() is called from both the natural path (the timer fired) and the teardown path (someone cancelled first). It's deliberately idempotent: whichever path arrives first wins, the other becomes a no-op.
The two bugs looked identical. They weren't.
This is the part that interested me most after fixing both: in the code, withSmoothLoading and withTemporarySuccess (the "saved!"-style feedback operator, which also uses a setTimeout to reset an indicator) have the same shape β a timer sitting between "the source finished" and "the indicator settles." My first instinct was that the fix for the detached timer should apply to both.
It shouldn't, and understanding why mattered more than either fix on its own.
withSmoothLoading holds the stream's own completion β the downstream complete/error only fires once minDuration has actually elapsed. That makes sense: the operator's contract is "I won't tell you the response arrived until the minimum time has really passed," so delaying completion is the operator being honest about when it actually finished. withTemporarySuccess, by contrast, fires its complete immediately β the indicator reset after duration (often 2s or more) is decoration layered on after the fact, like a toast or a checkmark, and has nothing to do with the real work that already finished. Delaying complete there would mean blocking whoever's listening with .subscribe(() => navigate()) β for a UI detail.
So the fixes ended up different by design, not by oversight: withSmoothLoading needed no new API, because the only missing piece was tying the timer to teardown that already existed. withTemporarySuccess got a new, opt-in parameter β { signal: AbortSignal } β because cancelling that reset is the caller's call to make, not something the operator should force by silently hiding a 2-second delay inside every complete.
// cancellation is opt-in β the reset stays fire-and-forget by default
const controller = new AbortController();
save$(id)
.pipe(withTemporarySuccess(setSaved, 2000, { signal: controller.signal }))
.subscribe();
// a new id arrived: cancel the previous request's reset before it
// steps on the indicator the new request is already driving
controller.abort();
Two bugs with the same "shape" β a timer detached from its subscription β but calling for opposite fixes, because what the timer represents is different in each case. That's the kind of thing that only shows up once a library moves past "passes my tests" and into "someone used it exactly as the README says, and nothing happened."
What's left
rx-state-bridge is on npm, and the code β both fixes, the regression tests for each, and the design notes that document this distinction so it doesn't turn into a repeat issue β is open on GitHub. If you're using Angular Signals with a library that accepts "callback or .set()" as an interface, it's worth double-checking the order of that typeof β it's easy to write the wrong way and impossible to notice without testing against the real object, not a mock.
Comments
Loading comments...
Join the conversation
Sign in with your account to comment on this article.