
signal() También Es una Función: el Bug Que Dejaba Muda Toda 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: toda operator se volvía un no-op silencioso con Angular
Cada operator de la librería (withLoading, catchToState, bindTo, las demás) termina escribiendo en un "indicador" de estado. Para aceptar tanto 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 contra useState 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 al otro 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);
}
Una línea movida de lugar, pero el efecto fue que toda operator de la librería — las seis — no hacía nada en silencio con Angular hasta este patch. Es el tipo de bug que solo un test escrito específicamente contra un signal() real de @angular/core (no un mock { set: vi.fn() }) podría detectar, porque un mock 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 — el timer no sabe que el operator fue cancelado
const remaining = minDuration - (Date.now() - startedAt);
timer(remaining).subscribe(() => {
applyIndicator(indicator, false);
emit();
});
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. unsubscribe() mata la subscription externa, pero 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 mover la espera dentro de la función de teardown del Observable construido manualmente, que RxJS ya llama automáticamente en cualquier 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(); // 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 bugs 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, porque lo único que faltaba era atar el timer al teardown que ya existía. 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 e imposible de notar sin probar contra el objeto real, no un mock.
Comentarios
Cargando comentarios...
Únete a la conversación
Inicia sesión con tu cuenta para comentar este artículo.