Pular para o conteúdo
< samuelsantana.dev />
Voltar para o Blog

signal() Também É Função: o Bug Que Deixava Todo Operator do rx-state-bridge Mudo com Angular

Samuel Santana
Publicado em 05 de agosto de 2026
AngularReactTypeScriptTesting

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, sub­scription 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: toda 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 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 useState do React, porque useState devolve 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 toda operator da lib — todas as seis — silenciosamente não fazia nada com Angular até esse patch. Foi o tipo de bug que só um teste escrito especificamente contra signal() do @angular/core (não um mock de { set: vi.fn() }) conseguiria pegar, porque um mock 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 é 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 — o timer não sabe que o operator foi cancelado
const remaining = minDuration - (Date.now() - startedAt);
timer(remaining).subscribe(() => {
  applyIndicator(indicator, false);
  emit();
});

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. unsubscribe() mata a subscription externa, mas 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 mover a espera pra dentro da função de teardown do Observable construído manualmente, que o RxJS já chama automaticamente em qualquer 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(); // 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 bugs pareciam iguais. Não eram.

Isso é 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 sua 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, porque a única coisa que faltava era prender o timer no teardown que já existia. 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 toda 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 impossível de perceber sem testar contra o objeto real, não um mock.

Comentários

Carregando comentários...

Participe da conversa

Entre com sua conta para comentar neste artigo.