Skip to content

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):

  1. Ler o código-fonte relevante
  2. Verificar console/logs
  3. Consultar banco de dados
  4. 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? → truncate CSS não funciona
  • Por que truncate não funciona? → forwardRef ausente no componente base, Radix não consegue medir/conter o elemento

2.3 Classificação do fix

TipoQuando usarRisco
PaliativoEmergência, sem tempo para root causeAlto (mascara o problema)
ContençãoLimita o impacto enquanto investigaMédio
EstruturalCausa raiz identificada e comprovadaBaixo

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):

  1. Migration SQL primeiro (adiciona antes de remover)
  2. Backend (alinhado com novo schema)
  3. 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ãoConsequênciaCorreção
Aplicar CSS sem investigar componente baseFix falha, sintoma migra (lateral→vertical)Investigar a raiz antes de estilizar
Assumir perda de dados sem consultar bancoPânico desnecessário, ações destrutivasSELECT antes de qualquer "recuperação"
Deployar schema change sem código alinhadoQueries quebram, dados "somem"Deploy atômico (migration+code)
Adicionar overflow-hidden sem min-w-0Container respeita max-w mas filhos grid expandemAmbos necessários em layouts grid/flex
Tratar warning do console como cosméticoWarning de ref causa falha real de medição/layoutWarnings são sintomas de bugs estruturais
Funções de hook sem useCallback em deps de useEffectLoop infinito de re-renders, UI congelauseCallback obrigatório em toda função retornada por hook
enabled que depende de dado que só vem da própria queryDeadlock circular, query nunca disparaPrimeira 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 throwReact Query acha que deu certo, não faz retrySempre throw em erro dentro de queryFn
Tratar timeout (504) como sessão expiradaLogout silencioso sem explicação ao usuárioDiferenciar _transient vs auth error, retry antes de deslogar
useEffect redundante chamando funções que React Query já auto-executaRequests duplicados no mount, risco de loopRemover 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

  1. truncate no SelectTrigger → Não funcionou porque o componente Select não usava forwardRef, impedindo Radix de medir o elemento
  2. overflow-hidden no DialogContent → O conteúdo parou de vazar lateralmente mas passou a crescer verticalmente
  3. max-w forçado no trigger → Limitou o trigger mas não o popover

Resolução definitiva

  1. Causa raiz: src/components/ui/select.tsx usava function components sem forwardRef
  2. Fix estrutural: Refatorar Select para usar React.forwardRef em todos os sub-componentes (Trigger, Content, Item, Label, Separator, ScrollUp/Down)
  3. Contenção complementar: max-h-[calc(100vh-2rem)] + overflow-y-auto no DialogContent
  4. 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

  1. Console warnings (Function components cannot be given refs) eram a pista direta da causa raiz
  2. Sintomas visuais diferentes (overflow lateral vs vertical) podem ter a mesma causa
  3. "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_cores usa padrão "RLS enabled, zero policies" — acesso exclusivo via service_role no 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_coordenadores retornado 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

  1. Features visuais condicionais (cor só se >1 coord) precisam de dados contextuais do backend — não assumir no frontend
  2. Tabelas auxiliares de UI (cores) seguem o padrão service_role do projeto para simplicidade
  3. 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

  1. Por que demora 5s? → 4 requests HTTP paralelos, cada um com overhead de auth+flag
  2. Por que cada request é lento? → Dentro de cada um, N queries sequenciais por olimpíada
  3. Por que N queries? → for (const olp of olimpiadas) { await supabase.from('fases')... } — loop sequencial

Resolução

  1. Action batch_init_resultados: Consolida 4 requests em 1 único
  2. .in('olimpiada_id', ids): Busca fases/inscrições/resultados de todas olimpíadas de uma vez
  3. Promise.all no backend para paralelizar queries independentes
  4. 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

  1. Por que mostra "sem olimpíadas"? → olimpiadas está []
  2. Por que []? → Query nunca executou (enabled: false)
  3. Por que enabled: false? → Depende de anoEdicao que 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: enabled nunca 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

  1. Por que a UI congela? → Loop infinito de re-renders saturando o event loop
  2. Por que loop infinito? → useEffect dispara a cada render porque deps mudam
  3. Por que deps mudam? → Funções retornadas pelo hook sem useCallback criam nova referência a cada render

Resolução

  1. useCallback em todas as 12 funções retornadas pelo hook
  2. Remoção do useEffect de mount redundante — React Query já auto-carrega via useQuery
  3. 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 como mensagens.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

text
Infra timeout (504) ──┬──→ auth-context: "sem sessão" → logout silencioso
                      └──→ banners: retorna [] → sem retry → tela vazia

1 falha de infra gerou 3 sintomas aparentemente independentes.

3 Porquês

  1. Por que logout silencioso? → auth-context trata qualquer falha do /me como sessão expirada
  2. Por que banners vazios? → queryFn retorna [] em erro → React Query acha que deu certo
  3. Por que tudo ao mesmo tempo? → Timeout de infra afetou ambos os endpoints simultaneamente

Resolução

  1. invokeEdge: retry automático (max 2) com backoff para 502/503/504
  2. auth-context: flag _transient diferencia timeout de sessão expirada; toast informativo + retry em 5s
  3. Banners: throw em erro dentro de queryFn → React Query faz retry
  4. Resultado: Timeout transitório é invisível para o usuário; sessão realmente expirada mostra toast explicativo

Regra Derivada

queryFn DEVE fazer throw em 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 diretor abre sem permissões, mesmo com features globais ativas
  • Botão "Salvar permissões" não produz feedback (silencioso)
  • Usuário logado como diretor vê sidebar/tabs inconsistentes

3 Porquês (Causa Raiz Múltipla)

  1. Por que permissões aparecem vazias?get_permissoes_usuario recebia só usuario_id + escola_id e pegava vinculos?.[0] (primeiro papel). Se coordenador veio primeiro, diretor recebia permissões do coordenador (ou zero se incompatíveis).

  2. Por que salvar não funciona? → Modelo de dados: usuarios_escola_permissoes com UNIQUE (usuario_id, escola_id, permissao) — sem papel_id. Salvar diretor sobrescrevia coordenador ou gerava conflito silencioso.

  3. Por que sem toast? → Wrappers em admin-usuarios.tsx engoliam erro no catch e retornavam { success: false } sem chamar olpToast. Frontend não reidratava após save.

Cascata de Falhas

text
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 feedback

1 falha estrutural (modelo de dados) gerou 4 sintomas aparentemente independentes.

Resolução (Estrutural — 5 camadas)

  1. Migration: Adicionou papel_id em usuarios_escola_permissoes e usuarios_escola_sub_permissoes. Novo UNIQUE: (usuario_id, escola_id, papel_id, permissao). Backfill de dados existentes por papel compatível.
  2. Backend CRUD: get_permissoes_usuario e update_permissoes_usuario recebem e filtram por papel_id explícito.
  3. Helpers: seedPermissionsForRole e removePermissionsForRole escopados por papel_id.
  4. Frontend admin: AdminPermissoesGrid recebe papel_id, implementa feedback com olpToast, rehydrata após save.
  5. /me: Resolve papel_id do papel ativo e retorna permissões isoladas.

Regras Derivadas

Toda tabela de permissão DEVE ter escopo de papel_id — nunca apenas usuario_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ãoConsequênciaCorreção
Permissões escopadas só por (usuario_id, escola_id)Papéis colidem na mesma escolaAdicionar papel_id ao modelo + queries
Backend pega "primeiro vínculo" sem papel_idUI mostra dados de outro papelReceber e filtrar por papel_id explícito
Wrapper com catch silenciosoUsuário sem feedback de saveolpToast em todo success/error path
Frontend mapeia papel_id: ''Backend não encontra vínculoPreservar UUID real do mapeamento
gestao-usuarios-escola INSERT sem papel_idRegistros órfãos → /me não encontra → AccessBlockedScreenResolver papel_id do vínculo ativo antes de INSERT
DELETE sem scoping por papel_idApaga permissões de todos os papéis.eq("papel_id", resolved) em todo DELETE
Falta seed de sub_permissoes no createTabs vazias até admin salvar manualmenteseedSubPermissoesForRole() após criação
Unique index com NULL (papel_id)Duplicatas ilimitadas no PostgreSQLALTER COLUMN papel_id SET NOT NULL
Backend não valida novo valor de enumFrontend envia valor válido, backend rejeita com 400 genéricoAtualizar 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

  1. Por que flash ao trocar tab? → Skeleton aparece por 1 frame antes do conteúdo
  2. Por que skeleton aparece? → Guard usa if (isLoading) return <Skeleton />, e isLoading é true no primeiro render do hook
  3. Por que isLoading é true se tem cache? → React Query inicializa useQuery com isLoading: true por 1 tick antes de detectar cache existente via queryKey

Resolução (2 Camadas)

Camada 1 — Sub-tabs: Trocar o guard de loading:

typescript
// ❌ 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:

text
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 aparece

Correção: mover setVisitedTabs para dentro do handleTabChange, no mesmo batch síncrono que setActiveTab:

typescript
// ❌ 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 useEffect para 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.memo aparentemente "não funciona"

3 Porquês

  1. Por que todas as rows re-renderizam ao editar uma?handleChange é chamado no useEffect de cada row a cada render
  2. Por que useEffect dispara? → Dependência instável (state local que muda a cada keystroke)
  3. 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:

typescript
// ❌ 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 via useEffect. Computar inline no save ou como useMemo. useEffect que chama setState/handleChange para campos derivados é anti-padrão — causa re-renders cascata que invalidam React.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

  1. Por que warning de ref?TooltipTrigger tenta passar ref para o filho, que não aceita
  2. Por que não aceita?SelectItem do Radix é gerenciado internamente e espera controle exclusivo de ref e focus
  3. 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:

tsx
// ❌ 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.