Files
inq-roi-simulador-web/sprints/sprint-5/INFORME_ETAPA_4_FREEZE_PERFIL.md

7.1 KiB
Raw Blame History

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 4B4F. 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 useruser?.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 (7f1f833a1a8b1c985c655aaf1f79) 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.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.