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ê:
- Frame 1: Placeholder aparece (dados =
[]) - 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:
- Usar
useQuerycomisLoadingexposto no contexto/hook - Renderizar
<Skeleton />ou loading state animado enquantoisLoading === true - NUNCA renderizar o fallback/placeholder como primeiro frame
- O fallback só aparece quando
isLoading === falseE dados estão vazios
Anti-padrão (PROIBIDO)
// ❌ 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
// ✅ 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:
- O
ProviderusauseQueryinternamente - Expõe
isLoadingno valor do contexto - Consumidores verificam
isLoadingantes de decidir o visual
// 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:
| Fase | Visual | Quando |
|---|---|---|
bootstrapping | Splash (se olp_last_activity existe) ou Skeleton de login | Mount inicial |
anonymous | Tela de login | Sem sessão ativa |
transitioning | Splash (azul + logo glow) | Login/troca de perfil ok, sincronizando /me |
authenticated | Dashboard | Sessão completa |
logging_out | Splash | Logout em andamento |
Fluxo atômico pós-login (sem reload)
Login/OTP/Select-role OK
→ startTransition() // authPhase = 'transitioning' → splash imediato
→ hydrateSession() // chama /me, aplica snapshot completo
→ authPhase = 'authenticated' → dashboard renderizaPROIBIDO: 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('/'):
- React re-renderiza com
usuario = null→ login aparece (Frame 1) - Browser reload → tela branca (Frame 2)
- App monta com
carregando: true→ loading gate (Frame 3) /meresolve → 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
- [ ]
isLoadingestá 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
setStateantes dewindow.location.replace()causa flash visual - [ ] Login/troca de perfil usa
startTransition()+hydrateSession(), nuncawindow.location.reload()
Caso de estudo: Cookie HttpOnly vs localStorage para Loading Gate
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):
// ❌ Cookie HttpOnly é invisível ao JS — sempre false
const hasAuth = document.cookie.includes('olp_auth');Padrão correto:
// ✅ 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 isLoadingsrc/App.tsx— loading gate por authPhasesrc/contexts/auth-context.tsx— máquina de estados AuthPhase + hydrateSessiondocs/development/CODING_STANDARDS.md— padrões gerais de código