Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions docs/hooks-audit-round3-2026-05.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Auditoria de Hooks — Round 3 — Maio 2026

> **Branch:** `fix/hooks-audit-round3-2026-05`
> **Escopo:** 378 arquivos de hooks em 21 diretórios de `src/hooks/`
> **Data:** 26/05/2026
> **Auditores:** Claude Sonnet 4.6 (análise automática) + TIPROMO (revisão)

---

## Metodologia

Leitura exaustiva de todos os hooks do projeto, verificando padrões de:

1. **Stale closures** — callbacks capturando state/props desatualizados
2. **Memory leaks** — timers, subscriptions e listeners não limpos no unmount
3. **Race conditions** — setState após unmount em promises assíncronas
4. **Redundância** — `useMemo` duplicados com mesmas deps
5. **Deps incorretas** — deps que causam re-runs desnecessários
6. **Computações fora de useMemo** — estruturas recalculadas em todo render

---

## Bugs Encontrados e Corrigidos

### BUG-08 🔴 P1 — `useKitAutoSave.ts`

**Sintoma:** Auto-save silenciosamente cancelado após recalculos de preço.

**Causa raiz:** `saveToDb` estava nas deps do snapshot `useEffect`. Qualquer mudança em `kitState` (incluindo `totalPrice` recalculado em background) recriava `saveToDb`, triggering o effect. O snapshot era igual → o effect retornava cedo — **mas o cleanup (clearTimeout) da iteração anterior ainda executava**, cancelando o timer pendente sem criar um novo.

**Fix:** Padrão `saveToDbRef` — ref atualizada a cada render, timeout lê do ref. `saveToDb` removido das deps do snapshot effect.

**Commit:** `e1a71ac6`

**Impacto:** Kit Builder pode perder trabalho do usuário sem aviso se o preço for recalculado durante os 5s de debounce.

---

### BUG-09 🟡 P2 — `useEntitySelectionMode.ts`

**Sintoma:** Computação desnecessária duplicada em seleções de novidades/reposições.

**Causa raiz:** `bulkCartProducts` e `selectedProducts` eram dois `useMemo` com código e deps identicamente iguais — loop de filter+map executado 2× por render com seleção ativa.

**Fix:** Remover `bulkCartProducts` como `useMemo` separado. Expô-lo como alias de `selectedProducts`.

**Commit:** `92836670`

---

### BUG-10 🔴 P1 — `useWorkspaceNotifications.tsx`

**Sintoma:** Polling de notificações nunca dispara 30s após uma notificação ser lida.

**Causa raiz:** `notifications.length` nas deps de `fetchNotifications`. Cada `markAsRead` → `setNotifications` → `notifications.length` muda → `fetchNotifications` recriado → polling `useEffect` re-executa → `clearInterval` + novo `setInterval` → timer de 30s resetado.

**Fix:** Substituir `notifications.length` por `notificationsLengthRef.current` (atualizado a cada render). Remover `notifications.length` das deps de `fetchNotifications`.

**Commit:** `be644b5b`

---

### BUG-11 🟠 P2 — `useGravacaoPriceV2.ts` (`useCustomizationPriceReactiveLegacy`)

**Sintoma:** Warning React "Can't perform a React state update on an unmounted component".

**Causa raiz:** Hook deprecated (`@deprecated`) mas ainda em uso em código legado. A promise `.then/.catch/.finally` não verifica se o componente ainda está montado antes de chamar `setPrice`, `setError`, `setLoading`.

**Fix:** Flag `let isMounted = true` + `return () => { isMounted = false }` no useEffect. Cada setState verificado com `if (isMounted)`.

**Commit:** `c1cff22c`

---

### BUG-12 🟠 P2 — `useTechniquePricing.ts`

**Sintoma:** Warning React + possível exibição de dados de técnica anterior sobreescrevendo a nova seleção.

**Causa raiz:** `fetchPriceOptions` definido dentro de `useEffect` sem mecanismo de cancelamento. Se `techniqueCode` mudar rapidamente (usuário navegando entre técnicas), a promise da chamada anterior resolve e chama `setPriceOptions`/`setError`/`setIsLoading` no componente que já estava em cleanup.

**Fix:** Flag `isMounted` com cleanup.

**Commit:** `2e9ddd0c`

---

### BUG-13 🟡 P3 — `useKitStockValidation.ts`

**Sintoma:** Performance — CPU spike em kits grandes ao re-render por scroll/hover.

**Causa raiz:** `stockByProduct` (Map) e `alerts` (Array) declarados como variáveis fora de `useMemo`. Em cada render, o loop O(n) que agrega estoque e verifica alertas executa novamente, mesmo que `stockData`, `box`, `items` e `kitQuantity` não tenham mudado.

**Fix:** Ambos encapsulados em um único `useMemo` com deps `[stockData, box, items, kitQuantity]`.

**Commit:** `b32767b5`

---

### BUG-14 🟡 P2 — `usePositionHistory.ts`

**Sintoma:** Primeiro passo de undo perdido em drag rápido de logo.

**Causa raiz:** `pushState` capturava `historyIndex` via closure e o usava no callback de `setHistory`. Se chamado duas vezes antes do re-render (ex: mouseMove gerando duas atualizações batched), a segunda chamada usava o mesmo `historyIndex` stale — `prev.slice(0, historyIndex+1)` cortava no mesmo ponto da primeira, efetivamente descartando o primeiro push.

**Fix:** Migrado para `useReducer` com reducer `historyReducer` que atualiza `history` e `historyIndex` atomicamente em um único dispatch. Sem stale closure possível.

**Commit:** `28068286`

---

### BUG-15 🟡 P3 — `useRecentlyViewed.ts`

**Sintoma:** Memory leak minor — timeout não limpo após unmount do componente.

**Causa raiz:** O `setTimeout` de 1s em `addToRecentlyViewed` que reseta o `lastAddedRef` não armazenava o id retornado. Se o componente desmontasse dentro desse segundo, o timeout continuava pendente.

**Fix:** `dedupeTimerRef` armazena o id do timeout. `useEffect` cleanup o limpa no unmount. Timeout anterior limpo antes de criar o próximo.

**Commit:** `869c2ab9`

---

### BUG-16 🟡 P3 — `useKitUndoRedo.ts`

**Sintoma:** Timeout de 100ms em undo/redo não limpo no unmount; instável em sistemas lentos.

**Causa raiz:** `setTimeout(() => { isRestoringRef.current = false; }, 100)` em `undo()` e `redo()` não armazenava o id retornado. Timeout não limpo no unmount. Chamadas rápidas de undo/redo podiam stackar múltiplos timers. `reset()` também não cancelava o timer em flight.

**Fix:** `restoreTimerRef` gerencia centralmente o timer. Timeout anterior limpo antes de criar o próximo. `useEffect` cleanup no unmount. `reset()` também limpa o timer.

**Commit:** `840027f2`

---

### BUG-17 🟠 P2 — `useGeoBlocking.ts`

**Sintoma:** Warning React "Can't perform a React state update on an unmounted component" ao navegar rapidamente pela área admin.

**Causa raiz:** `fetchCurrentCountry` faz `fetch('https://ipapi.co/json/')` sem `AbortController`. Se o admin navegar para outra página antes da resposta retornar (~200-500ms de latência), `setCurrentCountry` é chamado em componente já desmontado.

**Fix:** `fetchCurrentCountry` agora aceita `signal?: AbortSignal`. O `useEffect` cria um `AbortController`, passa o sinal para o fetch, e chama `controller.abort()` no cleanup. `AbortError` é silenciado.

**Commit:** `0ec1f22f`

---

## Resumo dos Commits

| Commit | Bug | Arquivo |
|---|---|---|
| `e1a71ac6` | BUG-08 | `src/hooks/kit-builder/useKitAutoSave.ts` |
| `92836670` | BUG-09 | `src/hooks/common/useEntitySelectionMode.ts` |
| `be644b5b` | BUG-10 | `src/hooks/ui/useWorkspaceNotifications.tsx` |
| `c1cff22c` | BUG-11 | `src/hooks/simulation/useGravacaoPriceV2.ts` |
| `2e9ddd0c` | BUG-12 | `src/hooks/simulation/useTechniquePricing.ts` |
| `b32767b5` | BUG-13 | `src/hooks/kit-builder/useKitStockValidation.ts` |
| `28068286` | BUG-14 | `src/hooks/simulation/usePositionHistory.ts` |
| `869c2ab9` | BUG-15 | `src/hooks/products/useRecentlyViewed.ts` |
| `840027f2` | BUG-16 | `src/hooks/kit-builder/useKitUndoRedo.ts` |
| `0ec1f22f` | BUG-17 | `src/hooks/admin/useGeoBlocking.ts` |

---

## Resumo por Diretório Auditado

| Diretório | Arquivos | Bugs |
|---|---|---|
| `kit-builder/` | 19 | BUG-08, BUG-13, BUG-16 |
| `common/` | 17 | BUG-09 |
| `ui/` | 16 | BUG-10 |
| `simulation/` | 18 | BUG-11, BUG-12, BUG-14 |
| `products/` | 54 | BUG-15 |
| `admin/` | 14 | BUG-17 |
| `auth/` | 10 | ✅ Nenhum |
| `quotes/` | 16 | ✅ Nenhum |
| `intelligence/` | 31 | ✅ Nenhum |
| `bi/` | 14 | ✅ Nenhum |
| `crm/` | 7 | ✅ Nenhum |
| `favorites/` | 8 | ✅ Nenhum |
| `comparison/` | 6 | ✅ Nenhum |
| `simulator/` | 8 | ✅ Nenhum |
| `voice/` | 12 | ✅ Nenhum |
| `tecnicas/` | 7 | ✅ Nenhum |
| `mockup/` | 5 | ✅ Nenhum |
| `gravacao/` | 6 | ✅ Nenhum |
| `collections/` | 3 | ✅ Nenhum |
| `dev/` | 2 | ✅ Nenhum |
| `stock/` | 2 | ✅ Nenhum |

**Total auditado:** 378 arquivos | **Bugs encontrados e corrigidos:** 10

---

## Histórico de Auditorias

| Round | Data | PR | Bugs |
|---|---|---|---|
| Round 1 | Abr 2026 | #427, #431 | BUG-01 a BUG-07 |
| Round 2 (testes) | Mai 2026 | #433 | 19 testes de regressão para Round 1 |
| **Round 3** | **Mai 2026** | **Este PR** | **BUG-08 a BUG-17** |
34 changes: 27 additions & 7 deletions src/hooks/admin/useGeoBlocking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,41 @@ export function useGeoBlocking() {
mode: 'whitelist',
});
const [isLoading, setIsLoading] = useState(true);
const [currentCountry, setCurrentCountry] = useState<{ code: string; name: string } | null>(null);
const [currentCountry, setCurrentCountry] = useState<{ code: string; name: string } | null>(
null,
);

const fetchCurrentCountry = useCallback(async () => {
// BUG-17 FIX: accept an AbortSignal so the fetch can be cancelled when the
// component unmounts. Without this, setCurrentCountry would be called on an
// already-unmounted component if the ipapi.co response arrived after unmount
// (typical round-trip is 200-500ms — well within navigation timing).
const fetchCurrentCountry = useCallback(async (signal?: AbortSignal) => {
try {
const response = await fetch('https://ipapi.co/json/');
const data = await response.json();
const response = await fetch('https://ipapi.co/json/', { signal });
const data = (await response.json()) as { country_code: string; country_name: string };
setCurrentCountry({
code: data.country_code,
name: data.country_name,
});
} catch (error) {
// AbortError is expected on unmount — silence it
if (error instanceof Error && error.name === 'AbortError') return;
console.error('Error fetching current country:', error);
}
}, []);

const fetchData = useCallback(async () => {
try {
const [countriesRes, settingsRes] = await Promise.all([
supabase.from('geo_allowed_countries').select('id, country_code, country_name, is_active, created_at').order('country_name'),
db.from('security_settings').select('id, setting_key, setting_value').eq('setting_key', 'geo_blocking').single(),
supabase
.from('geo_allowed_countries')
.select('id, country_code, country_name, is_active, created_at')
.order('country_name'),
db
.from('security_settings')
.select('id, setting_key, setting_value')
.eq('setting_key', 'geo_blocking')
.single(),
]);

if (countriesRes.error) throw countriesRes.error;
Expand All @@ -66,8 +81,13 @@ export function useGeoBlocking() {
}, []);

useEffect(() => {
fetchCurrentCountry();
// Create an AbortController so fetchCurrentCountry can be cancelled on unmount
const controller = new AbortController();
fetchCurrentCountry(controller.signal);
fetchData();
return () => {
controller.abort();
};
}, [fetchCurrentCountry, fetchData]);

const toggleEnabled = useCallback(
Expand Down
14 changes: 7 additions & 7 deletions src/hooks/common/useEntitySelectionMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,20 +185,20 @@ export function useEntitySelectionMode<TEntity extends SelectableEntity>({
[wizardMode, navigate, clearSelection],
);

const bulkCartProducts = useMemo(() => {
const ids = Array.from(selectedIds);
return filteredProducts
.filter((p) => ids.includes(p.product_id))
.map(entityToProduct);
}, [selectedIds, filteredProducts, entityToProduct]);

// BUG-09 FIX: previously bulkCartProducts and selectedProducts were two
// separate useMemo calls with identical code and deps — double computation
// on every render with active selection. Now selectedProducts is the single
// source of truth and bulkCartProducts is a plain alias with zero overhead.
const selectedProducts = useMemo(() => {
const ids = Array.from(selectedIds);
return filteredProducts
.filter((p) => ids.includes(p.product_id))
.map(entityToProduct);
}, [selectedIds, filteredProducts, entityToProduct]);

// Alias for backward compatibility with consumers expecting bulkCartProducts
const bulkCartProducts = selectedProducts;

const firstSelectedId =
selectedIds.size > 0 ? Array.from(selectedIds)[0] : "";
const firstSelectedProduct = filteredProducts.find(
Expand Down
17 changes: 15 additions & 2 deletions src/hooks/kit-builder/useKitAutoSave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ export function useKitAutoSave(
}
}, [user?.id, kitState, kitQuantity, autoSavedKitId, currentKitId, onKitIdCreated]);

// BUG-08 FIX: keep a stable ref to the latest saveToDb so the timeout
// always calls the most-recent version WITHOUT putting saveToDb in the
// snapshot effect deps. Previously, saveToDb in deps caused the cleanup
// (clearTimeout) to run on every kitState change — even when the snapshot
// hadn't changed — silently cancelling the pending auto-save timer.
const saveToDbRef = useRef(saveToDb);
useEffect(() => {
saveToDbRef.current = saveToDb;
}, [saveToDb]);

// Create a snapshot hash to detect meaningful changes
useEffect(() => {
if (isFirstRender.current) {
Expand All @@ -101,7 +111,10 @@ export function useKitAutoSave(
snapshotRef.current = newSnapshot;

if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(saveToDb, AUTO_SAVE_DELAY_MS);
// Read saveToDb via ref — this effect intentionally excludes saveToDb
// from its deps to prevent the timer from being cleared on non-snapshot
// state changes (e.g., price recalculations updating kitState.totalPrice).
timerRef.current = setTimeout(() => saveToDbRef.current(), AUTO_SAVE_DELAY_MS);

return () => {
if (timerRef.current) clearTimeout(timerRef.current);
Expand All @@ -112,7 +125,7 @@ export function useKitAutoSave(
kitState.personalization,
kitState.name,
kitQuantity,
saveToDb,
// saveToDb intentionally OMITTED — accessed via saveToDbRef instead
]);

// Update autoSavedKitId when currentKitId changes externally
Expand Down
Loading