# 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 `
` 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