7.1 KiB
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"):
- 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. - Bug de Radix UI /
react-remove-scroll: al cerrar el Sheet, el cleanup que restaurapointer-eventsdel<body>no se ejecutaba de forma confiable, dejando toda la página no interactiva — salvo los elementos del canvas BPMN, que fijanpointer-events: allexplí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 "") |
?? → ` |
|
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ó.
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.tssrc/auth/AuthContext.tsxsrc/components/ProfileSheet.tsxsrc/components/ui/sheet.tsx← fix finalsrc/lib/supabase.tssrc/store/library-store.tssrc/store/process-store.tssrc/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.