
signal() También Es una Función: el Bug Que Dejaba Mudo Todo Operator de rx-state-bridge con Angular
rx-state-bridge es una librería que escribí para resolver un problema tedioso y repetitivo: cada fetch en una UI termina con el mismo trío de loading/error/data reescrito a mano, subscription tras subscription. La idea era simple — operators de RxJS que escriben ese estado por ti, sin importar si el "estado" es un useState de React o un signal() de Angular. Publiqué la 1.0, los tests pasaban, el README tenía un ejemplo lado a lado para ambos frameworks. Bonito.
El primer issue serio llegó pocos días después, y la causa era una línea que había mirado docenas de veces sin ver el problema.
El bug: todo operator se volvía un no-op silencioso con Angular
Cada operator de la librería (withLoading, catchToState, bindTo, los demás) termina escribiendo en un "indicador" de estado. Para aceptar tanto el setter de useState (una función) como un signal de Angular (un objeto con .set()), existe una función interna, applyIndicator, que normaliza los dos formatos en una sola llamada. La primera versión hacía lo obvio: comprueba si es función, la llama directo; si no, asume que tiene .set() y llama eso.
// la versión con el bug
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);
}
Parece correcto. Pasa todos los tests escritos con un callback al estilo del setState de React, porque el setter de useState es de verdad solo una función — sin .set alguno.
El problema es el WritableSignal de Angular. Un signal no es solo un objeto con .set() — también es invocable: mySignal() es cómo se lee su valor actual. Entonces typeof indicator === 'function' también era true para un signal, y el if capturaba la rama equivocada primero. applyIndicator(mySignal, nuevoValor) se convertía, en la práctica, en mySignal(nuevoValor) — que no es un setter, es el getter siendo invocado con un argumento que ignora por completo. Ningún error, ningún warning. El valor simplemente nunca cambiaba.
Y lo peor: era exactamente el patrón de mi propio ejemplo de Angular en el README.
readonly loading = signal(false);
readonly data$ = source$.pipe(withSmoothLoading(this.loading, 500));
Ejecutando eso, this.loading nunca salía de false. No porque withSmoothLoading estuviera rota — porque la función que debía escribir en el signal estaba, sin avisar a nadie, leyéndolo.
La corrección: invertir el orden del check
La salida es comprobar .set primero, no typeof. Un WritableSignal tiene .set como propiedad; un callback plano ((value) => void) no. Comprobando .set primero, ambos formatos de signal (un objeto literal { set } y el WritableSignal invocable) caen en el mismo camino, y solo una función genuinamente "tonta" — sin .set — cae en la otra rama.
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);
}
Una línea movida de lugar, pero el efecto fue que todo operator de la librería — los seis — no hacía nada en silencio con Angular hasta este patch. Es el tipo de bug que solo un test con la forma real de un signal() — invocable y con .set() — podría detectar (un mock { set: vi.fn() } no lo detecta), porque un objeto plano con .set nunca reproduce el "también invocable" que es la parte traicionera.
El segundo bug: un timer que sobrevivía a su propio unsubscribe
Mientras investigaba ese, encontré otro en el mismo vecindario de código: withSmoothLoading. Su promesa es mantener el indicador de loading en true por al menos minDuration ms, aunque la respuesta llegue más rápido, para evitar el parpadeo de un spinner que aparece y desaparece en 50ms.
La implementación original programaba ese retraso con un timer(...).subscribe(...) suelto, sin ninguna conexión con la subscription externa:
// la versión con el bug — programado en un finalize(), cuando el complete ya había pasado hacia downstream
finalize(() => {
// ...
const remaining = minDuration - (Date.now() - startedAt);
timer(remaining).subscribe(() => applyIndicator(indicator, false));
}),
Funcionaba en los casos obvios. El problema aparece cuando alguien cancela la subscription durante esa ventana de espera — un componente desmontándose, un switchMap pasando a un nuevo request a mitad de camino. Pero el finalize corre después de que el complete ya pasó hacia downstream: la subscription externa ya está cerrada, un unsubscribe() en ese momento ya no alcanza nada, y el timer interno es su propia subscription, desconectada — no sabe que debería detenerse. Sigue contando y, cuando dispara, escribe false en un indicador que ahora pertenece a otro request, o que ya ni siquiera existe.
La corrección: atar el timer al teardown del propio Observable
La corrección no fue "hacer el timer cancelable" de forma aislada — fue retener el propio complete/error durante la espera, dentro de un Observable construido manualmente, para que la subscription siga abierta y su función de teardown — que RxJS ya llama automáticamente en cualquier unsubscribe — pueda cancelar el timer:
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(); // limpia el graceTimer pendiente, si lo hay
};
});
Ahora un unsubscribe() en cualquier momento — incluso a mitad de la propia ventana de gracia — cancela el setTimeout pendiente y resetea el indicador de inmediato, porque finish() se llama tanto desde el camino natural (el timer disparó) como desde el teardown (alguien canceló primero). Es deliberadamente idempotente: el camino que llegue primero gana, el otro se vuelve no-op.
Los dos timers parecían iguales. No lo eran.
Esta es la parte que más me interesó después de corregir ambos: en el código, withSmoothLoading y withTemporarySuccess (el operator de feedback tipo "¡guardado!", que también usa un setTimeout para resetear un indicador) tienen la misma forma — un timer entre "la fuente terminó" y "el indicador se estabiliza". Mi primer instinto fue pensar que la corrección del timer suelto debía aplicarse a ambos.
No debía, y entender por qué importó más que las dos correcciones juntas.
withSmoothLoading retiene la propia conclusión del stream — el complete/error downstream solo se dispara después de que minDuration haya pasado de verdad. Tiene sentido: el contrato del operator es "no te aviso que la respuesta llegó hasta que el tiempo mínimo realmente pasó", así que retrasar la conclusión es el operator siendo honesto sobre cuándo terminó de verdad. withTemporarySuccess, en cambio, dispara su complete de inmediato — el reset del indicador tras duration (con frecuencia 2s o más) es decoración añadida después del hecho, como un toast o un checkmark, y no tiene nada que ver con el trabajo real que ya terminó. Retrasar complete ahí significaría bloquear a quien esté escuchando con .subscribe(() => navigate()) — por un detalle de UI.
Entonces las correcciones terminaron siendo diferentes por diseño, no por descuido: withSmoothLoading no necesitó ninguna API nueva; bastó con retener su propia conclusión durante la ventana de gracia (como máximo minDuration) y atar el timer al teardown. withTemporarySuccess ganó un parámetro nuevo y opcional — { signal: AbortSignal } — porque cancelar ese reset es decisión de quien llama, no algo que el operator deba imponer escondiendo un retraso de 2 segundos dentro de cada complete.
// la cancelación es opcional — el reset sigue siendo fire-and-forget por defecto
const controller = new AbortController();
save$(id)
.pipe(withTemporarySuccess(setSaved, 2000, { signal: controller.signal }))
.subscribe();
// llegó un id nuevo: cancela el reset del request anterior antes de que
// pise el indicador que el nuevo request ya está controlando
controller.abort();
Dos bugs con la misma "forma" — un timer desconectado de su subscription — pero que piden correcciones opuestas, porque lo que el timer representa es distinto en cada caso. Es el tipo de cosa que solo aparece cuando una librería deja de "pasar mis tests" y pasa a "alguien la usó exactamente como dice el README, y no pasó nada".
Lo que queda
rx-state-bridge está en npm, y el código — ambas correcciones, los tests de regresión de cada una, y las notas de diseño que documentan esta distinción para que no se vuelva un issue repetido — está abierto en GitHub. Si usas Angular Signals con alguna librería que acepta "callback o .set()" como interfaz, vale la pena revisar el orden de ese typeof — es fácil escribirlo al revés y difícil de notar sin un test con la forma real del signal (invocable y con .set()), no un mock { set }.
Comentarios
Cargando comentarios...