Pular para o conteúdo
< samuelsantana.dev />
Voltar para o BlogDiagrama: typeof === 'function' é true para o setLoading do React e para o signal() do Angular; no signal, a chamada lê o valor em vez de gravar

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, 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...