Resolução de Problemas — Metodologia OLP
REGRA ZERO
Ler este documento ANTES de iniciar qualquer investigação ou implementação de correção.
1. Diagnóstico (antes de qualquer código)
1.1 Coletar evidências
- Screenshot/descrição do usuário → entender o SINTOMA
- Console logs → warnings e erros reais
- Network requests → respostas do backend
- Banco de dados → estado real dos dados
- NUNCA assumir a causa pelo sintoma
1.2 Separar problemas
- Cada sintoma pode ter causa independente
- Listar todos os problemas reportados
- Investigar cada um separadamente
- Só unificar se a causa raiz for comprovadamente a mesma
1.3 Eliminar hipóteses por camada
Ordem obrigatória (do mais barato ao mais caro):
- Ler o código-fonte relevante
- Verificar console/logs
- Consultar banco de dados
- Testar no browser (último recurso)
2. Análise de causa raiz
2.1 Perguntas obrigatórias
- "Isso é sintoma ou causa?"
- "Onde na cadeia (banco → backend → frontend → UI) o problema NASCE?"
- "Existem warnings/erros no console que apontam a causa?"
- "O fix anterior criou um novo sintoma?" (regressão)
2.2 Padrão "3 porquês"
Exemplo real do projeto:
- Por que o modal quebra? → Select expande além do container
- Por que o Select expande? →
truncateCSS não funciona - Por que
truncatenão funciona? →forwardRefausente no componente base, Radix não consegue medir/conter o elemento
2.3 Classificação do fix
| Tipo | Quando usar | Risco |
|---|---|---|
| Paliativo | Emergência, sem tempo para root cause | Alto (mascara o problema) |
| Contenção | Limita o impacto enquanto investiga | Médio |
| Estrutural | Causa raiz identificada e comprovada | Baixo |
SEMPRE preferir estrutural. Paliativo APENAS com nota de débito técnico documentada.
3. Planejamento da solução
3.1 Mapear impacto
- Listar TODOS os arquivos que usam o componente/função afetada
- Verificar se a correção quebra outros consumidores
- Se for componente base (
ui/*), o impacto é global
3.2 Ordem de execução
Para mudanças coordenadas (banco + backend + frontend):
- Migration SQL primeiro (adiciona antes de remover)
- Backend (alinhado com novo schema)
- Frontend (consome novo contrato)
Deploy atômico: tudo junto, nunca parcial.
3.3 Retrocompatibilidade
- Decisão EXPLÍCITA: manter ou não
- Se não manter: varrer TODAS as referências (
grep/search global) - Se manter: documentar quando será removido
4. Implementação
4.1 Princípio da cirurgia mínima
- Alterar APENAS o necessário para resolver a causa raiz
- Não refatorar código adjacente "de oportunidade"
- Cada mudança deve ser justificável pela causa raiz
4.2 Verificação pós-fix
- O sintoma original sumiu?
- Novos warnings/erros no console?
- Outros consumidores do componente continuam funcionando?
- O fix introduziu regressão visual ou funcional?
5. Anti-padrões (erros reais cometidos)
| Anti-padrão | Consequência | Correção |
|---|---|---|
| Aplicar CSS sem investigar componente base | Fix falha, sintoma migra (lateral→vertical) | Investigar a raiz antes de estilizar |
| Assumir perda de dados sem consultar banco | Pânico desnecessário, ações destrutivas | SELECT antes de qualquer "recuperação" |
| Deployar schema change sem código alinhado | Queries quebram, dados "somem" | Deploy atômico (migration+code) |
Adicionar overflow-hidden sem min-w-0 | Container respeita max-w mas filhos grid expandem | Ambos necessários em layouts grid/flex |
| Tratar warning do console como cosmético | Warning de ref causa falha real de medição/layout | Warnings são sintomas de bugs estruturais |
Funções de hook sem useCallback em deps de useEffect | Loop infinito de re-renders, UI congela | useCallback obrigatório em toda função retornada por hook |
enabled que depende de dado que só vem da própria query | Deadlock circular, query nunca dispara | Primeira chamada com enabled: true, filtro refinado em chamada subsequente |
| Query N+1 em loop por item (buscar fases por olimpíada) | Latência O(n), 20+ queries por page load | .in() consolidado + batch_init action |
queryFn retorna [] em erro em vez de throw | React Query acha que deu certo, não faz retry | Sempre throw em erro dentro de queryFn |
| Tratar timeout (504) como sessão expirada | Logout silencioso sem explicação ao usuário | Diferenciar _transient vs auth error, retry antes de deslogar |
useEffect redundante chamando funções que React Query já auto-executa | Requests duplicados no mount, risco de loop | Remover useEffect manual — confiar no useQuery |
6. Template de investigação
Para cada problema reportado, preencher antes de codar:
### Problema: [descrição em 1 linha]
- **Sintoma**: o que o usuário vê
- **Console**: warnings/erros relevantes
- **Hipótese 1**: [causa provável] → [como validar]
- **Hipótese 2**: [causa provável] → [como validar]
- **Causa raiz comprovada**: [após investigação]
- **Tipo de fix**: paliativo / contenção / estrutural
- **Arquivos afetados**: [lista]
- **Risco de regressão**: baixo / médio / alto
- **Validação**: [como confirmar que resolveu]7. Casos de estudo
Caso 1: Modal da Agenda (março/2026)
Contexto
Modal de detalhes da tarefa quebrava layout ao selecionar olimpíada com nome longo. Três tentativas de CSS falharam antes da resolução definitiva.
Tentativas falhadas
truncateno SelectTrigger → Não funcionou porque o componenteSelectnão usavaforwardRef, impedindo Radix de medir o elementooverflow-hiddenno DialogContent → O conteúdo parou de vazar lateralmente mas passou a crescer verticalmentemax-wforçado no trigger → Limitou o trigger mas não o popover
Resolução definitiva
- Causa raiz:
src/components/ui/select.tsxusava function components semforwardRef - Fix estrutural: Refatorar Select para usar
React.forwardRefem todos os sub-componentes (Trigger, Content, Item, Label, Separator, ScrollUp/Down) - Contenção complementar:
max-h-[calc(100vh-2rem)]+overflow-y-autono DialogContent - Resultado: Zero warnings, truncamento funcional, layout estável
Segundo problema (mesmo ticket)
- Sintoma: "Tarefas do dia 20/03 sumiram"
- Investigação: Query no banco → tarefas existem em outra escola do mesmo usuário
- Causa: Contexto de escola ativa diferente, não perda de dados
- Resolução: Nenhuma mudança de código necessária — comportamento correto
Lições
- Console warnings (
Function components cannot be given refs) eram a pista direta da causa raiz - Sintomas visuais diferentes (overflow lateral vs vertical) podem ter a mesma causa
- "Dados sumiram" quase sempre é problema de contexto/filtro, não perda real
Caso 2: Feature multi-camada com deploy atômico (2026-03-20)
Contexto
Implementação de cor de identificação por coordenador em tarefas. Envolve 3 camadas: migration SQL (nova tabela coordenador_cores), backend (3 actions em user-profile, JOIN em tarefas-escola), frontend (hooks, card visual, perfil).
Padrão aplicado
- Tabela sem RLS policies:
coordenador_coresusa padrão "RLS enabled, zero policies" — acesso exclusivo viaservice_roleno backend (padrão do projeto para tabelas de sistema). createSupabaseSystem()para queries em tabela sem policies (cores),createSupabaseClient(req)para queries com RLS (tarefas).- Promise.all para buscar tarefas + cores + contagem de coordenadores em paralelo, sem degradar latência.
- Meta no response: Campo
meta.total_coordenadoresretornado junto com a lista de tarefas para o frontend decidir se aplica cores (regra: >1 coordenador). - Paleta fixa: 10 cores pastel pré-definidas, validadas no backend. Cores em uso por outros coordenadores desabilitadas no frontend.
- Upsert com conflito:
onConflict: "usuario_id,escola_id"para idempotência na atribuição de cor.
Lições
- Features visuais condicionais (cor só se >1 coord) precisam de dados contextuais do backend — não assumir no frontend
- Tabelas auxiliares de UI (cores) seguem o padrão
service_roledo projeto para simplicidade - Deploy atômico: migration → backend → frontend, tudo na mesma entrega
Caso 3: Performance N+1 e Batching (março/2026)
Contexto
Tela de Resultados fazia 4 requests HTTP paralelos no mount, cada um passando pelo pipeline completo de auth + feature flag + CORS. No backend, cada olimpíada aderida disparava queries individuais para fases, inscrições e resultados — um padrão N+1 clássico.
Sintomas
- Page load ~5 segundos (com 3-4 olimpíadas)
- Network tab mostrava 4 requests paralelos para o mesmo backend
- Cada request interno fazia ~4 queries ao banco por olimpíada
3 Porquês
- Por que demora 5s? → 4 requests HTTP paralelos, cada um com overhead de auth+flag
- Por que cada request é lento? → Dentro de cada um, N queries sequenciais por olimpíada
- Por que N queries? →
for (const olp of olimpiadas) { await supabase.from('fases')... }— loop sequencial
Resolução
- Action
batch_init_resultados: Consolida 4 requests em 1 único .in('olimpiada_id', ids): Busca fases/inscrições/resultados de todas olimpíadas de uma vezPromise.allno backend para paralelizar queries independentes- Resultado: De ~5s para <1s
Padrão Replicável
Aplicado também no Mural (batch_init no mural-escola). Qualquer tela com 3+ requests para o mesmo backend é candidata.
Caso 4: Dependência Circular em useQuery (março/2026)
Contexto
useResultadosInit tinha enabled: anoEdicao !== null para filtrar por ano. Porém, anoEdicao só era populado quando a query retornava dados — criando um deadlock: query não roda → dados não vêm → ano nunca é setado → query nunca roda.
Sintomas
- Tela mostra "Nenhuma olimpíada encontrada" permanentemente
- Nenhum request disparado no Network tab (query
enabled: false) - Sem erros no console
3 Porquês
- Por que mostra "sem olimpíadas"? →
olimpiadasestá[] - Por que
[]? → Query nunca executou (enabled: false) - Por que
enabled: false? → Depende deanoEdicaoque só vem da própria query → deadlock circular
Resolução
enabled: true— primeira chamada sem filtro carrega dados base- Segunda chamada com ano específico usa queryKey diferente → cacheada separadamente
- Regra:
enablednunca deve depender de dado que só existe no resultado da própria query
Caso 5: Loop Infinito de Re-renders — Mensagens (março/2026)
Contexto
Hook useComunicacaoEscola retornava 12 funções imperativas sem useCallback. No componente comunicacao.tsx, essas funções eram usadas como dependências de useEffect. Nova ref a cada render → useEffect dispara → setState → re-render → loop infinito.
Sintomas
- UI congela completamente ao clicar em "Templates"
- Network tab mostra avalanche de requests idênticos (100+ em <2 segundos)
- Browser fica em estado "não responde"
- Cursor trava em estado pointer
Padrão de Detecção
Abrir Network tab → avalanche de requests idênticos com timestamp crescente em <1s é sinal inequívoco de loop infinito de re-renders.
3 Porquês
- Por que a UI congela? → Loop infinito de re-renders saturando o event loop
- Por que loop infinito? →
useEffectdispara a cada render porque deps mudam - Por que deps mudam? → Funções retornadas pelo hook sem
useCallbackcriam nova referência a cada render
Resolução
useCallbackem todas as 12 funções retornadas pelo hook- Remoção do
useEffectde mount redundante — React Query já auto-carrega viauseQuery - Resultado: Zero re-renders desnecessários, UI responsiva
Regra Derivada
Toda função retornada por hook custom DEVE usar
useCallback. Dependências devem ser estáveis (queryClient, mutation, refetch) — nunca dados reativos comomensagens.length.
Caso 6: Timeout Transitório vs Sessão Expirada (março/2026)
Contexto
Supabase retornou 504 (timeout de infra transitório). O auth-context interpretou como "sessão expirada" → logout silencioso. Simultaneamente, banners falharam com 500 e retornavam [] em vez de throw → React Query não fez retry. Resultado: usuário redirecionado para login com tela de banners vazia.
Cascata de Falhas
Infra timeout (504) ──┬──→ auth-context: "sem sessão" → logout silencioso
└──→ banners: retorna [] → sem retry → tela vazia1 falha de infra gerou 3 sintomas aparentemente independentes.
3 Porquês
- Por que logout silencioso? → auth-context trata qualquer falha do
/mecomo sessão expirada - Por que banners vazios? →
queryFnretorna[]em erro → React Query acha que deu certo - Por que tudo ao mesmo tempo? → Timeout de infra afetou ambos os endpoints simultaneamente
Resolução
invokeEdge: retry automático (max 2) com backoff para 502/503/504auth-context: flag_transientdiferencia timeout de sessão expirada; toast informativo + retry em 5s- Banners:
throwem erro dentro dequeryFn→ React Query faz retry - Resultado: Timeout transitório é invisível para o usuário; sessão realmente expirada mostra toast explicativo
Regra Derivada
queryFnDEVE fazerthrowem erro — nunca retornar estado vazio. Retornar[]em erro impede retry e mascara falha.
Caso 7: Colisão de Permissões Multi-Papel na Mesma Escola (abril/2026)
Contexto
Usuário com diretor e coordenador na mesma escola. Ao abrir o badge diretor no modal admin, as permissões apareciam vazias (sem tabs, sub-flags). Ao clicar "Salvar permissões", nada acontecia — sem toast de sucesso nem erro. Na visão do usuário, o papel selecionado ficava sem acesso correto.
Sintomas
- Badge
diretorabre sem permissões, mesmo com features globais ativas - Botão "Salvar permissões" não produz feedback (silencioso)
- Usuário logado como
diretorvê sidebar/tabs inconsistentes
3 Porquês (Causa Raiz Múltipla)
Por que permissões aparecem vazias? →
get_permissoes_usuariorecebia sóusuario_id + escola_ide pegavavinculos?.[0](primeiro papel). Secoordenadorveio primeiro,diretorrecebia permissões do coordenador (ou zero se incompatíveis).Por que salvar não funciona? → Modelo de dados:
usuarios_escola_permissoescom UNIQUE(usuario_id, escola_id, permissao)— sempapel_id. Salvardiretorsobrescreviacoordenadorou gerava conflito silencioso.Por que sem toast? → Wrappers em
admin-usuarios.tsxengoliam erro nocatche retornavam{ success: false }sem chamarolpToast. Frontend não reidratava após save.
Cascata de Falhas
Modelo sem papel_id ──┬──→ get_permissoes: retorna papel errado → UI vazia
├──→ update_permissoes: salva contra papel errado → 0 permissões válidas
├──→ /me: resolve menu sem distinguir papel ativo → sidebar inconsistente
└──→ Frontend: wrappers silenciosos → sem feedback1 falha estrutural (modelo de dados) gerou 4 sintomas aparentemente independentes.
Resolução (Estrutural — 5 camadas)
- Migration: Adicionou
papel_idemusuarios_escola_permissoeseusuarios_escola_sub_permissoes. Novo UNIQUE:(usuario_id, escola_id, papel_id, permissao). Backfill de dados existentes por papel compatível. - Backend CRUD:
get_permissoes_usuarioeupdate_permissoes_usuariorecebem e filtram porpapel_idexplícito. - Helpers:
seedPermissionsForRoleeremovePermissionsForRoleescopados porpapel_id. - Frontend admin:
AdminPermissoesGridrecebepapel_id, implementa feedback comolpToast, rehydrata após save. /me: Resolvepapel_iddo papel ativo e retorna permissões isoladas.
Regras Derivadas
Toda tabela de permissão DEVE ter escopo de
papel_id— nunca apenasusuario_id + escola_id. Se um usuário pode ter múltiplos papéis na mesma escola, escopo por papel é obrigatório.
Toda operação de save DEVE ter feedback visual (toast sucesso/erro) — sem caminhos silenciosos. Wrappers que engolem erros com
catch { return { success: false } }são 🔴 CRÍTICO.
Pós-save: rehydratar estado da UI — após save com sucesso, invalidar cache ou refetch para que a UI reflita o estado persistido. Nunca confiar no estado local pré-save.
Anti-padrão Catalogado
| Anti-padrão | Consequência | Correção |
|---|---|---|
Permissões escopadas só por (usuario_id, escola_id) | Papéis colidem na mesma escola | Adicionar papel_id ao modelo + queries |
Backend pega "primeiro vínculo" sem papel_id | UI mostra dados de outro papel | Receber e filtrar por papel_id explícito |
| Wrapper com catch silencioso | Usuário sem feedback de save | olpToast em todo success/error path |
Frontend mapeia papel_id: '' | Backend não encontra vínculo | Preservar UUID real do mapeamento |
gestao-usuarios-escola INSERT sem papel_id | Registros órfãos → /me não encontra → AccessBlockedScreen | Resolver papel_id do vínculo ativo antes de INSERT |
DELETE sem scoping por papel_id | Apaga permissões de todos os papéis | .eq("papel_id", resolved) em todo DELETE |
| Falta seed de sub_permissoes no create | Tabs vazias até admin salvar manualmente | seedSubPermissoesForRole() após criação |
Unique index com NULL (papel_id) | Duplicatas ilimitadas no PostgreSQL | ALTER COLUMN papel_id SET NOT NULL |
| Backend não valida novo valor de enum | Frontend envia valor válido, backend rejeita com 400 genérico | Atualizar array de validação no backend ao adicionar valor no frontend (ex: estrutura: 'por_segmento' rejeitado em olimpiada-helpers.ts L649) |
Caso 8: Flash de Skeleton em Tabs com Cache Pré-populado (abril/2026)
Contexto
Seção Controle com 3 tabs usando Lazy Mount Once. O batch_init popula cache de todas as tabs em 1 request. Ao trocar de tab, a nova tab monta e exibe skeleton por 1 frame antes de renderizar os dados que já estão no cache.
Sintomas
- Flash visual de ~16ms (1 frame) ao clicar em tab pela primeira vez
- Skeleton aparece e desaparece instantaneamente ("pisca")
- Dados aparecem corretamente logo após — não é bug de fetch
3 Porquês
- Por que flash ao trocar tab? → Skeleton aparece por 1 frame antes do conteúdo
- Por que skeleton aparece? → Guard usa
if (isLoading) return <Skeleton />, eisLoadingétrueno primeiro render do hook - Por que
isLoadingétruese tem cache? → React Query inicializauseQuerycomisLoading: truepor 1 tick antes de detectar cache existente viaqueryKey
Resolução (2 Camadas)
Camada 1 — Sub-tabs: Trocar o guard de loading:
// ❌ ANTES — skeleton aparece mesmo com cache populado
if (isLoading) return <Skeleton />;
// ✅ DEPOIS — skeleton só aparece quando cache está genuinamente vazio
if (isLoading && !data) return <Skeleton />;Camada 2 — Container (causa raiz principal): O visitedTabs era atualizado via useEffect, que executa DEPOIS do render. Isso causava 1 frame em branco entre setActiveTab e a adição ao Set:
1. Clique → handleTabChange → setActiveTab("aplicacoes")
2. Re-render: currentTab = "aplicacoes", MAS visitedTabs NÃO tem "aplicacoes"
3. isVisited = false → return null → ⚡ FRAME EM BRANCO ⚡
4. useEffect dispara → adiciona ao visitedTabs → re-render → conteúdo apareceCorreção: mover setVisitedTabs para dentro do handleTabChange, no mesmo batch síncrono que setActiveTab:
// ❌ ANTES — useEffect assíncrono causa frame em branco
useEffect(() => {
if (currentTab && !visitedTabs.has(currentTab)) {
setVisitedTabs(prev => { const next = new Set(prev); next.add(currentTab); return next; });
}
}, [currentTab]);
const handleTabChange = useCallback((newTab) => {
tryNavigate(() => setActiveTab(newTab));
}, [tryNavigate]);
// ✅ DEPOIS — batch síncrono, zero frames em branco
const handleTabChange = useCallback((newTab) => {
tryNavigate(() => {
setVisitedTabs(prev => {
if (prev.has(newTab)) return prev;
const next = new Set(prev); next.add(newTab); return next;
});
setActiveTab(newTab);
});
}, [tryNavigate]);Regra Derivada
Estado de "lazy mount" (visitedTabs, mountedPanels, etc.) DEVE ser atualizado no mesmo handler síncrono que muda a tab ativa. Usar
useEffectpara derivar estado de montagem de tabs causa frames em branco inevitáveis.Em componentes com cache pré-populado, SEMPRE guardar o skeleton com
isLoading && !data.
→ Ref: docs/development/ATOMIC_RENDERING.md
Caso 9: useEffect Redundante Causando Re-renders em Componentes Memoizados (abril/2026)
Contexto
Tab Aplicações com tabela editável inline. Cada row é React.memo. Um useEffect calculava horario_termino = horario_inicio + duracao_minima e chamava handleChange para atualizar o estado.
Sintomas
- Ao editar um campo em qualquer row, TODAS as rows re-renderizam
- Performance visivelmente degradada com >10 olimpíadas
React.memoaparentemente "não funciona"
3 Porquês
- Por que todas as rows re-renderizam ao editar uma? →
handleChangeé chamado nouseEffectde cada row a cada render - Por que useEffect dispara? → Dependência instável (state local que muda a cada keystroke)
- Por que useEffect existe? → Cálculo derivado (
termino = inicio + duracao) foi implementado como side-effect em vez de computação inline
Resolução (Estrutural)
Remover o useEffect. Mover o cálculo de horario_termino para o momento do save:
// ❌ ANTES — useEffect dispara handleChange → invalida React.memo
useEffect(() => {
if (horarioInicio && duracaoMinima) {
const termino = calcularTermino(horarioInicio, duracaoMinima);
handleChange(id, 'horario_termino', termino);
}
}, [horarioInicio, duracaoMinima]);
// ✅ DEPOIS — campo derivado computado no save
const handleSave = () => {
const termino = calcularTermino(dados.horario_inicio, dados.duracao_minima);
salvar({ ...dados, horario_termino: termino });
};Regra Derivada
Campos derivados (ex:
termino = inicio + duracao) NUNCA devem ser calculados viauseEffect. Computar inline no save ou comouseMemo.useEffectque chamasetState/handleChangepara campos derivados é anti-padrão — causa re-renders cascata que invalidamReact.memo.
Caso 10: Tooltip Wrapper em SelectItem Causa Warning de Ref (abril/2026)
Contexto
Na tab Aplicações, SelectItem do Radix UI foi envolvido em TooltipTrigger para mostrar texto completo de opções longas.
Sintomas
- Console warning: "Function components cannot be given refs"
- Tooltip não funciona corretamente dentro do Select
- Potencial quebra de acessibilidade (focus management do Radix)
3 Porquês
- Por que warning de ref? →
TooltipTriggertenta passarrefpara o filho, que não aceita - Por que não aceita? →
SelectItemdo Radix é gerenciado internamente e espera controle exclusivo de ref e focus - Por que wrapper foi adicionado? → Tentativa de melhorar UX com tooltip em opções longas, sem investigar compatibilidade Radix
Resolução
Remover o TooltipTrigger de dentro do Select. Usar atributo title nativo do HTML:
// ❌ ANTES — wrapper Radix em primitiva Radix
<TooltipTrigger>
<SelectItem value="x">{textoLongo}</SelectItem>
</TooltipTrigger>
// ✅ DEPOIS — title nativo, sem conflito de ref
<SelectItem value="x" title={textoLongo}>{textoLongo}</SelectItem>Regra Derivada
Nunca envolver primitivas Radix (SelectItem, DropdownMenuItem, etc.) em outro wrapper Radix (Tooltip, Popover). Cada primitiva Radix espera controle exclusivo de ref e focus. Composição entre primitivas Radix distintas causa conflitos de ref forwarding e quebra de acessibilidade.