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ónprocessLock— elcatch (e instanceof LockAcquireTimeoutError)haceawait previousOperation→ sipreviousOperationnunca resuelve, el chain queda bloqueadoGoTrueClient.jslínea ~25:lockAcquireTimeout: 5000— el default que se pisa con el cambio encreateClient()GoTrueClient.jslínea ~2239:getSession()usathis.lockAcquireTimeout(no -1)
Qué NO entra
- Cambios en
AuthContext.tsxoSupabaseAuthService.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
src/lib/supabase.ts: agregarlockAcquireTimeout: 30000, eliminargetSessionSafe()src/store/library-store.ts: eliminar import +await getSessionSafe()src/store/process-store.ts: eliminar import + ambosawait getSessionSafe()src/features/report/useReportData.ts: eliminar import +await getSessionSafe()src/components/ProfileSheet.tsx: agregarsaveErrorstate + timeout 10s + UInpm run build— debe dar 0 errores TypeScriptnpm run test— 528+ Vitest verdesgit push origin main— OBLIGATORIO antes de validar en producción- 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 buildlimpionpm run test— 528+ Vitest verdesgit push origin mainejecutado antes de validar en producción- Validación visual del director antes del OK formal