# Informe — Resolución del freeze al guardar perfil (Sprint 5, Etapa 4)

**Fecha:** 2026-06-23
**Síntoma reportado:** al guardar nombre/empresa en el Sheet de "Editar perfil", la aplicación deja de responder a clicks en cualquier parte de la pantalla. Persistía tras múltiples deploys.
**Estado final:** resuelto. Confirmado en producción por el director.

---

## Resumen ejecutivo

Hubo **dos bugs independientes superpuestos** bajo el mismo síntoma ("la app se congela"):

1. **Contención de `processLock`** (Supabase Auth v2): operaciones de sincronización de sesión al login competían por un lock interno compartido con el guardado de perfil, pudiendo bloquearlo indefinidamente si el servidor tardaba.
2. **Bug de Radix UI / `react-remove-scroll`**: al cerrar el Sheet, el cleanup que restaura `pointer-events` del `<body>` no se ejecutaba de forma confiable, dejando toda la página no interactiva — salvo los elementos del canvas BPMN, que fijan `pointer-events: all` explícitamente vía su propio CSS y por eso seguían respondiendo.

El primero se resolvió en las etapas 4B–4F. El segundo, que era la causa del síntoma final reportado, se resolvió en la etapa 4H (dos iteraciones).

---

## Cronología de intentos

| Etapa | Hipótesis | Fix aplicado | Resultado |
|---|---|---|---|
| 4B (`dd361b1`) | `LibraryPage` recargaba en cada referencia nueva de `user` | `useEffect` dep `user` → `user?.id` | Insuficiente — no era la causa raíz completa |
| 4D (`496c227`) | Deadlock documentado en GoTrueClient.js L3213: callback `async` de `onAuthStateChange` hace `await` de otra llamada Supabase mientras el lock interno está activo | Callback de `onAuthStateChange` pasa a ser síncrono; las queries de DB se mueven a un `useEffect` separado fuera del lock | Necesario pero no suficiente |
| 4B-bis (`3d3ae99`) | El guardado de perfil seguía pasando por `supabase.from().update()`, que adquiere el lock vía `getSession()` internamente | Reemplazo por `fetch` nativo con token leído de `localStorage` | Mitiga, pero introduce su propio problema (ver 4G) |
| 4B-bis (`602957b`) | Las queries de sync de perfil al login podían colgar el lock indefinidamente si el servidor no respondía | `AbortController` con timeout de 6s en esas queries | Buena práctica defensiva, no resuelve el síntoma final |
| 4E (`3ebfd65`) | `getSessionSafe()` (barrera con `Promise.race` + timeout) abandonaba la Promise de `getSession()` al ganar el timeout, dejando una operación huérfana ocupando el lock para siempre | `lockAcquireTimeout: 30000` + eliminación completa de `getSessionSafe()` en 4 archivos | Correcto, resolvía un freeze real pero distinto al reportado al final |
| 4F (`1c53299`) | El botón "Guardar" aparecía deshabilitado porque `user.name` llegaba como cadena vacía (Google devolvía `full_name: ""` y el operador `??` no hace fallback con `""`) | `??` → `||` en 4 puntos de la cadena de propagación del nombre | Corrige un bug real distinto (botón disabled, no freeze) |
| 4G (`7f1f833` → `a1a8b1c` → `985c655` → `aaf1f79`) | El propio fetch nativo, al cerrar el Sheet de forma optimista, dejaba un overlay de Radix "stuck" en el DOM | Patrón optimista (cerrar antes de persistir) + defer de `updateUser` 350ms para no interferir con la animación de cierre de Radix | Mitigaba parcialmente, no resolvía la causa estructural |
| **4H (`1aa1486`)** | El cleanup de `react-remove-scroll` no restauraba `pointer-events` del body al cerrar | Wrapper de `onOpenChange` en el componente `Sheet` con limpieza diferida 400ms | **No se ejecutaba** — error de diagnóstico, ver abajo |
| **4H-2 (`c93d8e8`)** | — | `useEffect` que observa el prop `open` directamente en lugar de `onOpenChange` | **Confirmado funcionando en producción** |

---

## Causa raíz final (4H-2)

`onOpenChange` de Radix `Dialog.Root` (base del `Sheet` de shadcn/ui) es un canal **unidireccional**: Radix lo invoca cuando decide cerrar por iniciativa propia (Escape, click en el overlay, botón X). Pero `ProfileSheet.handleSave()` cierra el Sheet llamando directamente `onOpenChange(false)`, que en realidad es `setProfileOpen(false)` del componente padre (`AppHeader`) — un cambio de estado controlado desde **afuera** de Radix. En ese flujo, Radix nunca dispara su propio callback `onOpenChange`.

El primer intento de fix (4H) interceptaba ese callback, por lo que nunca se ejecutaba en el caso real que estaba pasando en producción. El fix correcto (4H-2) observa el prop `open` en sí mismo vía `useEffect`, lo cual cubre ambos canales de cierre sin importar cuál lo disparó.

```tsx
function Sheet({ open, ...props }: React.ComponentPropsWithoutRef<typeof SheetPrimitive.Root>) {
  React.useEffect(() => {
    if (open) return
    const timeoutId = window.setTimeout(() => {
      const stillHasOpenDialog = document.querySelector('[role="dialog"][data-state="open"]')
      if (!stillHasOpenDialog && document.body.style.pointerEvents === 'none') {
        document.body.style.pointerEvents = ''
      }
    }, 400)
    return () => window.clearTimeout(timeoutId)
  }, [open])

  return <SheetPrimitive.Root open={open} {...props} />
}
```

El fix vive en el primitivo `Sheet` compartido (`src/components/ui/sheet.tsx`), no en `ProfileSheet` — protege a cualquier Sheet de la aplicación, presente o futuro.

---

## Observación metodológica

Las primeras ~10 iteraciones (4B a 4G) se basaron en **lectura de código fuente y razonamiento sobre el comportamiento esperado** de Supabase Auth y Radix UI: diagnósticos plausibles, bien fundamentados, pero formulados sobre supuestos del comportamiento del sistema en producción sin observar directamente ese estado.

El punto de quiebre fue cuando el usuario compartió el **HTML real renderizado de la pantalla congelada**. Ese único dato — `<body style="pointer-events: none;">` — redujo el espacio de hipótesis de inmediato: ya no era necesario razonar sobre locks de Supabase, deadlocks de GoTrueClient, o timing de animaciones; el síntoma estaba literalmente escrito en el atributo `style` del elemento raíz. A partir de ahí, el fix llegó en dos iteraciones (una con un error de diagnóstico menor — el canal de `onOpenChange` equivocado — corregido de inmediato al confirmarse que no funcionaba).

**Lección operativa:** ante un bug de UI que persiste a través de varios fixes basados en teoría, pedir evidencia observable directa (HTML del DOM, captura de Network, captura de consola) antes de seguir iterando sobre hipótesis de código es más eficiente que razonar en capas sobre el comportamiento esperado de librerías de terceros. El código dice lo que *debería* pasar; el DOM en vivo dice lo que *está* pasando.

---

## Archivos modificados (total de la resolución)

- `src/auth/SupabaseAuthService.ts`
- `src/auth/AuthContext.tsx`
- `src/components/ProfileSheet.tsx`
- `src/components/ui/sheet.tsx` ← fix final
- `src/lib/supabase.ts`
- `src/store/library-store.ts`
- `src/store/process-store.ts`
- `src/features/report/useReportData.ts`

**Commits:** `dd361b1`, `496c227`, `3d3ae99`, `602957b`, `3ebfd65`, `1c53299`, `7f1f833`, `a1a8b1c`, `985c655`, `aaf1f79`, `1aa1486`, `c93d8e8`

**Tests:** 528 Vitest verdes en cada commit. Build limpio en cada commit.
