Files
inq-roi-simulador-web/sprints/sprint-5/ETAPA_4E_PROMPT.md
Marcos Benítez 882edd230d
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
a ver si ahora sí guarda el perfil
2026-06-20 19:06:58 -03:00

8.5 KiB

Sprint 5 — Etapa 4E: Fix definitivo del freeze al guardar perfil

Contexto

InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Supabase v2.106.2.

Etapas 4B, 4C, 4D intentaron corregir el freeze al guardar perfil. Ninguna resolvió el problema. Esta etapa identifica y corrige la causa raíz real.


Causa raíz real — auditada en código fuente de @supabase/auth-js

Componente 1: getSessionSafe() abandona Promises en PROCESS_LOCKS

getSessionSafe() en src/lib/supabase.ts está implementada así:

await Promise.race([
  supabase.auth.getSession(),  // Promise A — va al chain de processLock
  new Promise((resolve) => setTimeout(resolve, 4000)),  // timeout 4s
])

Cuando el timeout gana, getSessionSafe() resuelve. Pero la Promise A de getSession() sigue corriendo en background. Internamente, getSession() llamó processLock(name, acquireTimeout, fn_session), que actualizó PROCESS_LOCKS[name] = op_A antes de ceder el hilo. La operación op_A está en vuelo (token refresh via red) con PROCESS_LOCKS[name] apuntando a ella.

Componente 2: lockAcquireTimeout = 5000ms demasiado corto

El cliente Supabase usa lockAcquireTimeout = 5000ms por defecto. Cada llamada a getSession() pasa ese timeout a processLock(name, 5000, fn).

La cadena del freeze

t=0    → library-store.load() llama getSessionSafe()
           → getSession() → processLock → PROCESS_LOCKS[name] = op_A
           → op_A empieza: token refresh (request HTTP a Supabase Auth)

t=4s   → getSessionSafe() timeout gana → resuelve
           → op_A sigue corriendo en background (PROCESS_LOCKS[name] = op_A, PENDIENTE)

t=4s   → library queries lanzan: supabaseGroupRepo.getAll() etc.
           → _getAccessToken() → getSession() → _acquireLock(5000, fn_B)
           → processLock(5000): previousOperation = op_A (aún pendiente)
           → espera hasta que op_A resuelva... o hasta 5s

t=9s   → ProcessLockAcquireTimeoutError para op_B
           → PROCESS_LOCKS[name] = wrapper_B
           → wrapper_B hace: await previousOperation (= op_A, AÚN COLGADA)
           → wrapper_B nunca resuelve mientras op_A esté colgada

t=X    → Usuario abre ProfileSheet → hace click en Guardar
           → supabase.from('users').update() → getSession() → processLock
           → previousOperation = wrapper_B (NUNCA RESUELVE)
           → FREEZE PERMANENTE

La 4D fue 100% correcta: el callback de onAuthStateChange ya no tiene queries. Pero el freeze viene de esta cadena completamente diferente, que ocurre en getSessionSafe() de los stores.


Fix — 4 archivos

Cambio 1: src/lib/supabase.ts

Dos cambios:

A) Aumentar lockAcquireTimeout a 30s:

export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    lock: processLock,
    // Aumentado de 5000 (default) a 30000ms.
    // Con 5s, un token refresh lento dispara ProcessLockAcquireTimeoutError,
    // cuyo handler hace await previousOperation (colgada) → chain bloqueado permanente.
    // 30s da margen para refresh en redes lentas o cold starts de Supabase Auth.
    lockAcquireTimeout: 30000,
  },
})

B) Eliminar getSessionSafe():

Eliminar TODA la función getSessionSafe() — declaración y export. No dejar ni el tipo, ni un stub.

La función tenía un problema de diseño: el Promise.race([getSession(), timeout]) abandona la Promise de getSession() cuando gana el timeout. La Promise abandonada deja una operación en PROCESS_LOCKS[name] que bloquea el chain.

Cambio 2: src/store/library-store.ts

Eliminar la línea await getSessionSafe() y el import correspondiente:

// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'

// ELIMINAR esta línea en load():
await getSessionSafe()

No reemplazar con nada. Las queries a Supabase ya llaman getSession() internamente vía _getAccessToken(), que espera initializePromise automáticamente. El Promise.race de 15s que ya existe es suficiente como safety net general.

Cambio 3: src/store/process-store.ts

Mismo tratamiento — hay 2 usos de getSessionSafe() (líneas 41 y 57):

// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'

// ELIMINAR ambas líneas:
await getSessionSafe()  // una en loadProcess(), otra en loadActivities()

No reemplazar. Misma razón que en library-store.

Cambio 4: src/features/report/useReportData.ts

Mismo tratamiento — 1 uso:

// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'

// ELIMINAR la línea:
await getSessionSafe()

Cambio 5: src/components/ProfileSheet.tsx

Agregar timeout de 10s + estado de error visual. La red de seguridad: si en algún caso extremo la operación tarde más de 10s, el botón se re-habilita y se muestra un mensaje, en lugar de quedar congelado indefinidamente.

const [saveError, setSaveError] = useState<string | null>(null)

useEffect(() => {
  if (open && user) {
    setName(user.name)
    setCompany(user.company ?? '')
    setSaveError(null)  // ← limpiar error al abrir
  }
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])

async function handleSave() {
  if (!user || !name.trim()) return
  setSaving(true)
  setSaveError(null)
  try {
    const savePromise = supabase
      .from('users')
      .update({ name: name.trim(), company: company.trim() || null })
      .eq('id', user.id)

    const timeoutPromise = new Promise<never>((_, reject) =>
      setTimeout(
        () => reject(new Error('La operación tardó demasiado. Revisá tu conexión e intentá de nuevo.')),
        10_000
      )
    )

    const { error } = await Promise.race([savePromise, timeoutPromise])
    if (error) throw error
    updateUser({ name: name.trim(), company: company.trim() || undefined })
    onOpenChange(false)
  } catch (err) {
    setSaveError(err instanceof Error ? err.message : 'Error al guardar')
  } finally {
    setSaving(false)
  }
}

En el JSX del Sheet, mostrar el error si existe (después del bloque de inputs, antes del footer):

{saveError && (
  <p className="text-xs text-destructive mt-2">{saveError}</p>
)}

Verificación — qué buscar en el código fuente de Supabase

Si querés confirmar antes de implementar, estos son los puntos exactos a verificar en node_modules/@supabase/auth-js/dist/module/:

  • locks.js: función processLock — el catch (e instanceof LockAcquireTimeoutError) hace await previousOperation → si previousOperation nunca resuelve, el chain queda bloqueado
  • GoTrueClient.js línea ~25: lockAcquireTimeout: 5000 — el default que se pisa con el cambio en createClient()
  • GoTrueClient.js línea ~2239: getSession() usa this.lockAcquireTimeout (no -1)

Qué NO entra

  • Cambios en AuthContext.tsx o SupabaseAuthService.ts (la 4D fue correcta)
  • Cambios en el sistema de simulación, heatmap, o ComparisonTable
  • Ninguna feature nueva
  • No agregar nuevos getSessionSafe() equivalentes en ningún lado

Orden de ejecución

  1. src/lib/supabase.ts: agregar lockAcquireTimeout: 30000, eliminar getSessionSafe()
  2. src/store/library-store.ts: eliminar import + await getSessionSafe()
  3. src/store/process-store.ts: eliminar import + ambos await getSessionSafe()
  4. src/features/report/useReportData.ts: eliminar import + await getSessionSafe()
  5. src/components/ProfileSheet.tsx: agregar saveError state + timeout 10s + UI
  6. npm run build — debe dar 0 errores TypeScript
  7. npm run test — 528+ Vitest verdes
  8. git push origin main — OBLIGATORIO antes de validar en producción
  9. Commit: fix(sprint-5/etapa-4e): lockAcquireTimeout 30s + eliminar getSessionSafe — corta freeze en guardar perfil

Criterios de validación

  • Guardar nombre en ProfileSheet → app NO se congela → nombre actualizado en dropdown
  • Guardar empresa → app NO se congela → empresa aparece en dropdown
  • Cmd+R después de guardar → valores persisten (Supabase)
  • Biblioteca carga correctamente después de eliminar getSessionSafe() de library-store
  • Workspace carga correctamente después de eliminar getSessionSafe() de process-store
  • Reporte carga correctamente después de eliminar getSessionSafe() de useReportData
  • Si save tarda > 10s → botón se re-habilita → mensaje de error visible (no freeze)
  • npm run build limpio
  • npm run test — 528+ Vitest verdes
  • git push origin main ejecutado antes de validar en producción
  • Validación visual del director antes del OK formal