
signal() Também É Função: o Bug Que Deixava Todo Operator do rx-state-bridge Mudo com Angular
rx-state-bridge é uma lib que escrevi pra resolver um problema chato e repetitivo: todo fetch numa UI acaba com o mesmo trio de loading/error/data reescrito à mão, subscription atrás de subscription. A ideia era simples — operators de RxJS que escrevem esse estado pra você, sem importar se o "estado" é um useState do React ou um signal() do Angular. Publiquei a 1.0, os testes passavam, o README tinha exemplo pros dois frameworks lado a lado. Bonito.
O primeiro issue sério chegou poucos dias depois, e a causa era uma linha que eu tinha escrito olhando pra ela dúzias de vezes sem ver o problema.
O bug: todo operator virava um no-op silencioso com Angular
Todo operator da lib (withLoading, catchToState, bindTo, os outros) termina escrevendo num "indicador" de estado. Pra aceitar tanto o setter do useState (uma função) quanto um signal do Angular (um objeto com .set()), existe uma função interna, applyIndicator, que normaliza os dois formatos numa chamada só. A primeira versão dela fazia o óbvio: checa se é função, chama direto; senão, assume que tem .set() e chama isso.
// versão com o 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 correto. Passa em todos os testes com callback no estilo do setState do React, porque o setter do useState é mesmo uma função — o setLoading do exemplo é só uma função de um argumento, sem .set nenhum.
O problema é o WritableSignal do Angular. Um signal não é só um objeto com .set() — ele também é chamável: mySignal() é como você lê o valor atual. Então typeof indicator === 'function' era true pra um signal também, e o if capturava o caminho errado primeiro. applyIndicator(mySignal, novoValor) virava, na prática, mySignal(novoValor) — que não é um setter, é o getter sendo chamado com um argumento que ele ignora completamente. Nenhum erro, nenhum warning. O valor simplesmente nunca mudava.
E o pior: era exatamente o padrão do meu próprio exemplo de Angular no README.
readonly loading = signal(false);
readonly data$ = source$.pipe(withSmoothLoading(this.loading, 500));
Rodando isso, this.loading nunca saía de false. Não porque withSmoothLoading estivesse quebrada — porque a função que deveria escrever no signal estava, sem avisar ninguém, lendo ele.
A correção: inverter a ordem do check
A saída é checar .set primeiro, não typeof. Um WritableSignal tem .set como propriedade; uma função de callback pura ((value) => void) não tem. Checando .set primeiro, os dois formatos de signal ({ set } literal e o WritableSignal chamável) caem no mesmo caminho, e só uma função de fato "burra" — sem .set — sobra pro fallback.
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);
}
Uma linha movida de lugar, mas o efeito é que todo operator da lib — todos os seis — silenciosamente não fazia nada com Angular até esse patch. Foi o tipo de bug que só um teste com a forma real de um signal() — chamável e com .set() — conseguiria pegar (um mock de { set: vi.fn() } não pega), porque um objeto simples com .set nunca reproduz o "também é chamável" que é a parte traiçoeira.
O segundo bug: um timer que sobrevivia ao próprio unsubscribe
Enquanto investigava esse, encontrei outro na mesma vizinhança de código: withSmoothLoading. A promessa dela é a seguinte — segurar o indicador de loading em true por pelo menos minDuration ms, mesmo que a resposta chegue mais rápido, pra evitar aquele flicker de spinner que aparece e some em 50ms.
A implementação original agendava esse atraso com um timer(...).subscribe(...) solto, sem ligação nenhuma com a subscription de fora:
// versão com o bug — agendado num finalize(), quando o complete já tinha passado adiante
finalize(() => {
// ...
const remaining = minDuration - (Date.now() - startedAt);
timer(remaining).subscribe(() => applyIndicator(indicator, false));
}),
Funcionava nos casos óbvios. O problema aparece quando alguém cancela a subscription durante essa janela de espera — um componente desmontando, um switchMap trocando de request no meio do caminho. Só que o finalize roda depois que o complete já passou adiante: a subscription externa já está fechada, um unsubscribe() nessa hora não alcança mais nada, e o timer interno é uma subscription própria, desconectada — ele não sabe que devia parar. Ele continua contando e, quando estoura, escreve false num indicador que já pertence a outro request, ou que já nem existe mais.
A correção: prender o timer ao teardown do próprio Observable
A correção não foi "tornar o timer cancelável" isoladamente — foi segurar o próprio complete/error durante a espera, num Observable construído manualmente, para que a subscription continue aberta e a função de teardown dele — que o RxJS já chama automaticamente em qualquer unsubscribe — consiga cancelar o 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(); // limpa o graceTimer se ainda estiver pendente
};
});
Agora um unsubscribe() a qualquer momento — inclusive no meio da própria janela de graça — cancela o setTimeout pendente e zera o indicador na hora, porque finish() é chamado tanto pelo caminho natural (o timer estourou) quanto pelo teardown (alguém cancelou primeiro). É idempotente de propósito: qualquer um dos dois caminhos que chegar primeiro vence, o outro vira no-op.
Os dois timers pareciam iguais. Não eram.
Essa é a parte que mais me interessou depois de corrigir os dois: no código, withSmoothLoading e withTemporarySuccess (o operator de feedback tipo "salvo!", que também usa um setTimeout pra resetar um indicador) têm a mesma forma — um timer entre "a fonte terminou" e "o indicador se estabiliza". Minha primeira reação foi achar que a correção do timer solto deveria se aplicar às duas.
Não deveria, e entender por quê foi mais importante que os dois fixes juntos.
withSmoothLoading segura a própria conclusão do stream — o complete/error downstream só dispara depois que minDuration passou. Faz sentido: o contrato do operator é "eu não te aviso que a resposta chegou até o tempo mínimo ter passado de verdade", então atrasar a conclusão é o operator sendo honesto sobre quando ele realmente terminou. withTemporarySuccess, em compensação, dispara seu complete imediatamente — o reset do indicador depois de duration (frequentemente 2s ou mais) é decoração pós-fato, tipo um toast ou um checkmark, e não tem nada a ver com o trabalho real que já terminou. Atrasar complete ali significaria travar o .subscribe(() => navigate()) de quem estiver ouvindo — por causa de um detalhe de UI.
Então as correções ficaram diferentes por design, não por descuido: withSmoothLoading não ganhou nenhuma API nova; bastou segurar a própria conclusão durante a janela de graça (no máximo minDuration) e prender o timer ao teardown. withTemporarySuccess ganhou um parâmetro novo e opt-in — { signal: AbortSignal } — porque cancelar o reset ali é uma decisão de quem chama, não algo que o operator deveria impor escondendo um atraso de 2 segundos em todo complete.
// cancelamento é opt-in — o reset continua fire-and-forget por padrão
const controller = new AbortController();
save$(id)
.pipe(withTemporarySuccess(setSaved, 2000, { signal: controller.signal }))
.subscribe();
// um id novo chegou: cancela o reset do request anterior antes que ele
// pise no indicador que o novo request já está controlando
controller.abort();
Dois bugs com a mesma "forma" — um timer desconectado da subscription — mas que pedem correções opostas, porque o que o timer representa é diferente em cada um. Isso é o tipo de coisa que só aparece depois que a lib sai de "passa nos meus testes" pra "alguém usou exatamente como o README manda e nada aconteceu".
O que fica
rx-state-bridge está no npm e o código — os dois fixes, os testes de regressão pra cada um, e as notas de design que documentam essa distinção pra não virar issue de novo — está aberto no GitHub. Se você usa Angular Signals com alguma lib que aceita "callback ou .set()" como interface, vale checar a ordem desse typeof — é fácil de escrever do jeito errado e difícil de perceber sem um teste com a forma real do signal (chamável e com .set()), não um mock de { set }.
Comentários
Carregando comentários...