fix(crm-vivo): o carimbo de entrega atravessa o hook como ref, nao como valor congelado

O `useRealtimeChannel` devolvia `ultimaEntrega.current` — leitura de ref DENTRO
do render. O React 19 recusa isso por lint, e a recusa estava certa: o numero
sai congelado naquele render, a ref muda depois e nada redesenha. Quem recebia
so via o valor novo se ALGUMA OUTRA COISA causasse render.

Funcionava por acidente — o TanStack Query redesenha ao invalidar. E falharia
exatamente na janela entre a entrega e esse redesenho, que e onde o refetch de
seguranca dispara: ele leria carimbo velho, concluiria "o canal nao trouxe
nada" e acusaria divergencia numa mudanca legitima. O mesmo comparador
sempre-verdadeiro que ja tinha me pegado uma vez, chegando por outra porta.

Virar useState resolveria a propagacao e traria pior: o valor entra nas
dependencias do efeito e o canal RE-ASSINA a cada evento, perdendo eventos na
reassinatura. A ref atravessa a fronteira do hook e quem le e o timer — que
roda fora do render e enxerga `.current` sempre fresco.

De quebra, o consumidor perde um ref inteiro (`entregaRef`, que so existia para
copiar o valor de volta para uma ref) e a escrita de `assinaturaRef` sai do
corpo do render para um efeito.

typecheck 0 · lint 0 errors (148 warnings, o baseline do repo) · 424 testes
unitarios verdes em 70 arquivos.
This commit is contained in:
Rafael Melgaço
2026-07-25 16:54:45 -03:00
parent 1fd16aa8b8
commit 9d5b1f5f59
2 changed files with 46 additions and 16 deletions
+22 -6
View File
@@ -1,5 +1,5 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import { createClient } from "@/lib/supabase/browser";
import type { RealtimeChannel } from "@supabase/supabase-js";
@@ -120,8 +120,22 @@ function esperarAuth(supabase: ReturnType<typeof createClient>): Promise<void> {
export function useRealtimeChannel(opts: UseRealtimeChannelOpts): {
status: RealtimeStatus;
/** Instante da última entrega deste canal, ou null se nunca entregou nada. */
ultimaEntrega: number | null;
/**
* Instante da última entrega deste canal (`.current` é null se nunca entregou).
*
* ⚠️ DEVOLVE A REF, NÃO O VALOR, e isso é correção e não estilo: ler
* `.current` aqui no render entregaria um número CONGELADO naquele render —
* a ref muda depois e nada redesenha, então quem recebeu ficaria com carimbo
* velho até algo mais causar um render. Funcionava por acidente (a query
* redesenha ao invalidar), e falharia justamente na janela entre a entrega e
* esse redesenho, que é onde o detector de perda dispara.
*
* Virar `useState` resolveria a propagação e criaria pior: o valor entra nas
* dependências do efeito e o canal RE-ASSINA a cada evento, perdendo eventos
* na reassinatura. Quem lê isto é um timer — roda fora do render e enxerga
* `.current` sempre fresco.
*/
ultimaEntrega: RefObject<number | null>;
} {
const { name, postgresChanges, broadcast, onChange, enabled = true } = opts;
@@ -139,8 +153,10 @@ export function useRealtimeChannel(opts: UseRealtimeChannelOpts): {
* que a tela mostra é indistinguível de "nada aconteceu no intervalo", e a
* checagem só consegue REPROVAR, nunca aprovar.
*
* `useRef` e não `useState`: isto não redesenha nada, e virar dependência de
* efeito faria o canal re-assinar a cada evento — perdendo eventos na janela.
* `useRef` e não `useState` porque virar dependência de efeito faria o canal
* re-assinar a cada evento, perdendo eventos na janela da reassinatura. A
* ref ATRAVESSA a fronteira do hook em vez de ser lida aqui — ver o tipo de
* retorno, onde está por que ler `.current` no render seria defeito.
*/
const ultimaEntrega = useRef<number | null>(null);
@@ -222,5 +238,5 @@ export function useRealtimeChannel(opts: UseRealtimeChannelOpts): {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [name, enabled, instanceId, postgresChanges?.event, postgresChanges?.table, postgresChanges?.filter, postgresChanges?.schema, broadcast?.event]);
return { status, ultimaEntrega: ultimaEntrega.current };
return { status, ultimaEntrega };
}
+24 -10
View File
@@ -1,6 +1,6 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
/**
* O REFETCH DE SEGURANÇA — uma peça com três papéis.
@@ -42,8 +42,15 @@ interface Opts<T> {
* sensível ao que o realtime deveria ter trazido.
*/
assinatura: (dado: T | undefined) => string;
/** Instante da última entrega do canal (de `useRealtimeChannel`). */
ultimaEntrega: number | null;
/**
* A REF do instante da última entrega do canal (de `useRealtimeChannel`).
*
* Ref e não valor porque a leitura acontece dentro do timer, e o timer roda
* fora do render: um valor capturado no render estaria congelado no instante
* errado — exatamente na janela entre a entrega e o redesenho, que é quando
* este detector dispara.
*/
ultimaEntrega: RefObject<number | null>;
/** De quanto em quanto tempo conferir. */
intervaloMs?: number;
enabled?: boolean;
@@ -71,12 +78,17 @@ export function useRefetchDeSeguranca<T>({
ultimaDivergencia: null,
ultimaVerificacao: null,
});
// Refs para o efeito não re-montar a cada render e o intervalo não reiniciar
// Ref para o efeito não re-montar a cada render e o intervalo não reiniciar
// — reiniciar adiaria a verificação para sempre numa tela que redesenha.
//
// A escrita mora num efeito, não no corpo do render: render pode ser
// descartado ou reexecutado pelo React 19, e escrever ali é efeito colateral
// que ninguém rastreia. Como quem lê é o timer (depois do render, sempre), a
// ref já está atualizada quando importa.
const assinaturaRef = useRef(assinatura);
assinaturaRef.current = assinatura;
const entregaRef = useRef(ultimaEntrega);
entregaRef.current = ultimaEntrega;
useEffect(() => {
assinaturaRef.current = assinatura;
}, [assinatura]);
const verificar = useCallback(async () => {
const antes = assinaturaRef.current(qc.getQueryData<T>(queryKey));
@@ -98,9 +110,9 @@ export function useRefetchDeSeguranca<T>({
// divergência em toda mudança legítima, e um detector que grita sempre é
// desligado na primeira semana.
const mudou = depois !== antes;
const entrega = ultimaEntrega.current;
const canalTrouxe =
entregaRef.current !== null &&
(prev.ultimaVerificacao === null || entregaRef.current > prev.ultimaVerificacao);
entrega !== null && (prev.ultimaVerificacao === null || entrega > prev.ultimaVerificacao);
const perdeu = mudou && !canalTrouxe;
return {
divergencias: perdeu ? prev.divergencias + 1 : prev.divergencias,
@@ -108,7 +120,9 @@ export function useRefetchDeSeguranca<T>({
ultimaVerificacao: Date.now(),
};
});
}, [qc, queryKey]);
// `ultimaEntrega` é ref (identidade estável): entra na lista por higiene,
// sem recriar o callback nem reiniciar o intervalo.
}, [qc, queryKey, ultimaEntrega]);
useEffect(() => {
if (!enabled) return;