Skip to content

Regra: Renderização Atômica (Anti-Flash)

Padrão obrigatório para todo componente que depende de dados assíncronos para decidir entre estados visuais distintos (ex: placeholder vs dados reais).

Problema

Quando um componente usa useState([]) + useEffect(fetch), ele renderiza imediatamente com o estado vazio. Se o estado vazio dispara um fallback/placeholder visual, o usuário vê:

  1. Frame 1: Placeholder aparece (dados = [])
  2. Frame 2 (500-2000ms depois): Dados reais chegam → componente re-renderiza

Isso causa um flash visual — o placeholder "pisca" antes do conteúdo real.

Regra

Todo componente que depende de dados assíncronos para decidir entre "estado A" (ex: placeholder) ou "estado B" (ex: dados reais) DEVE:

  1. Usar useQuery com isLoading exposto no contexto/hook
  2. Renderizar <Skeleton /> ou loading state animado enquanto isLoading === true
  3. NUNCA renderizar o fallback/placeholder como primeiro frame
  4. O fallback só aparece quando isLoading === false E dados estão vazios

Anti-padrão (PROIBIDO)

typescript
// ❌ Causa flash: estado inicia vazio, fallback aparece, dados chegam, pisca
const [data, setData] = useState([]);
useEffect(() => {
  fetchData().then(setData);
}, []);

return data.length > 0 ? <RealContent /> : <Placeholder />;

Padrão correto

typescript
// ✅ Skeleton durante loading, fallback só após confirmação de vazio
const { data = [], isLoading } = useQuery({ queryKey: ['key'], queryFn: fetchData });

if (isLoading) return <Skeleton className="h-[52px] w-full rounded-lg" />;
return data.length > 0 ? <RealContent data={data} /> : <Placeholder />;

Aplicação em contextos compartilhados

Quando o dado assíncrono é compartilhado via Context API:

  1. O Provider usa useQuery internamente
  2. Expõe isLoading no valor do contexto
  3. Consumidores verificam isLoading antes de decidir o visual
typescript
// No Provider
const { data, isLoading } = useQuery({ ... });
const value = { data, isLoading, ... };

// No consumidor
const { data, isLoading } = useMyContext();
if (isLoading) return <Skeleton />;

Caso de estudo: Autenticação — Máquina de Estados (AuthPhase)

A partir da v4.0, o AuthProvider usa uma máquina de estados explícita (authPhase) em vez do binário carregando:

FaseVisualQuando
bootstrappingSplash (se olp_last_activity existe) ou Skeleton de loginMount inicial
anonymousTela de loginSem sessão ativa
transitioningSplash (azul + logo glow)Login/troca de perfil ok, sincronizando /me
authenticatedDashboardSessão completa
logging_outSplashLogout em andamento

Fluxo atômico pós-login (sem reload)

text
Login/OTP/Select-role OK
→ startTransition()        // authPhase = 'transitioning' → splash imediato
→ hydrateSession()         // chama /me, aplica snapshot completo
→ authPhase = 'authenticated' → dashboard renderiza

PROIBIDO: Usar window.location.reload() para sincronizar sessão após login ou troca de perfil. O /me retorna snapshot atômico completo (usuário, papéis, menu, permissões, canary, realtime token, senha definida). O reloadComSplash() em auth.ts é mantido apenas para uso excepcional (erros de infra), nunca em fluxos de login.

Contenção visual no index.html

O <body> possui style="background:#0f172a" inline para que mesmo durante hard refreshes (F5, erros), o browser nunca pinte branco nativo antes do React montar.

Caso de estudo: Logout + Reload

Quando logout() faz setUsuario(null) antes de window.location.replace('/'):

  1. React re-renderiza com usuario = null → login aparece (Frame 1)
  2. Browser reload → tela branca (Frame 2)
  3. App monta com carregando: true → loading gate (Frame 3)
  4. /me resolve → login renderiza de novo (Frame 4)

Solução: Não limpar estado React antes do reload. O reload destrói tudo — a limpeza é redundante e causa o Frame 1 fantasma. O loading gate deve mostrar skeleton visual (não div branca) para suavizar a transição.

Regra: window.location.replace() já destrói o estado React. Nunca faça setState antes de um reload completo se o setState causa mudança visual.

Checklist

  • [ ] O estado inicial não renderiza fallback visual
  • [ ] isLoading está exposto e verificado antes do render condicional
  • [ ] Skeleton/loading state tem dimensões compatíveis com o conteúdo final (evita layout shift)
  • [ ] Fallback só aparece após isLoading === false + dados vazios
  • [ ] Nenhum setState antes de window.location.replace() causa flash visual
  • [ ] Login/troca de perfil usa startTransition() + hydrateSession(), nunca window.location.reload()

Quando o loading gate precisa decidir qual visual mostrar (splash vs skeleton de login), não use cookies HttpOnly como sinal. O cookie olp_auth é HttpOnly — document.cookie.includes('olp_auth') retorna sempre false no JavaScript.

Anti-padrão (PROIBIDO):

typescript
// ❌ Cookie HttpOnly é invisível ao JS — sempre false
const hasAuth = document.cookie.includes('olp_auth');

Padrão correto:

typescript
// ✅ localStorage como proxy de sessão prévia (apenas para bootstrap)
const hadSession = !!localStorage.getItem('olp_last_activity');

O olp_last_activity é gravado pelo hydrateSession() e pelo hook useInactivityTimeout durante uso ativo, e removido no logout. É seguro como sinal visual para o bootstrap — não contém dados sensíveis, apenas decide qual loading state mostrar no mount inicial. A autenticação real continua via cookie HttpOnly validado pelo backend no /me. Para transições pós-login, o authPhase é o gatilho principal (não localStorage).

Referências

  • src/components/content-context.tsx — exemplo de migração useState→useQuery com isLoading
  • src/App.tsx — loading gate por authPhase
  • src/contexts/auth-context.tsx — máquina de estados AuthPhase + hydrateSession
  • docs/development/CODING_STANDARDS.md — padrões gerais de código