7.9 KiB
Sprint 5 — Etapa 4D: Fix definitivo — deadlock en onAuthStateChange
Causa raíz real (auditada en código fuente de supabase-js v2.106.2)
La línea 3213 de GoTrueClient.js documenta explícitamente:
"A callback can be an
asyncfunction and it runs synchronously during the processing of the changes causing the event. You can easily create a dead-lock by usingawaiton a call to another method of the Supabase library."
SupabaseAuthService.onAuthStateChange viola exactamente este contrato. El callback hace await supabase.from('users').select('company, name') desde dentro del callback de auth, que corre con el lock interno de GoTrueClient activo (lockAcquired = true).
La cadena de deadlock:
INITIAL_SESSION fires
→ GoTrueClient adquiere lock (lockAcquired = true)
→ fn() (operación auth) corre → llama _notifyAllSubscribers
→ _notifyAllSubscribers await nuestro callback (via Promise.all)
→ nuestro callback llama supabase.from('users').select(...)
→ select() necesita token → llama getSession() → llama _acquireLock()
→ _acquireLock ve lockAcquired = true → entra al path REENTRANT (sin timeout)
→ path reentrant espera a fn() → fn() espera al callback → callback espera al select → DEADLOCK CIRCULAR
→ lockAcquired queda true PARA SIEMPRE
→ toda llamada posterior a getSession() cuelga indefinidamente
→ incluye supabase.from('users').update() del perfil → app freezes
Los tres fixes anteriores (guard en onAuthStateChange, useEffect [open], user?.id en LibraryPage) no resolvieron el problema porque ninguno tocó la causa real: queries de Supabase dentro del callback de auth.
Fix — arquitectura correcta
El callback de onAuthStateChange debe ser sincrónico en su lógica de auth: recibir el evento, construir el AuthUser básico, llamar callback(). Los side effects que necesitan DB (upsert, lectura de company/name) se ejecutan fuera del lock, en un useEffect de React.
Cambio 1 — src/auth/SupabaseAuthService.ts
Eliminar TODAS las llamadas a supabase.from(...) del interior del callback. El callback solo llama callback(buildAuthUser(session.user)):
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => { // ← NO async: no necesita await
try {
if (!session?.user) {
if (event !== 'INITIAL_SESSION') callback(null)
return
}
if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN') return
// Llamar callback INMEDIATAMENTE con datos básicos del token OAuth.
// NO hacer queries a Supabase desde aquí — el lock de auth está activo
// y cualquier llamada reentrante causaría deadlock permanente (ver GoTrueClient.js L3213).
// La sincronización con la tabla users (company, name, upsert) ocurre en AuthContext.
callback(this.buildAuthUser(session.user))
} catch (err) {
console.error('Error en onAuthStateChange:', err)
if (session?.user) callback(this.buildAuthUser(session.user))
else if (event !== 'INITIAL_SESSION') callback(null)
}
}
)
return () => subscription.unsubscribe()
}
Agregar también un método público en SupabaseAuthService para la sincronización con DB (usado desde AuthContext):
async syncProfileFromDB(userId: string): Promise<Pick<AuthUser, 'name' | 'company'> | null> {
const { data } = await supabase
.from('users')
.select('company, name')
.eq('id', userId)
.maybeSingle()
if (!data) return null
return {
name: (data.name as string | null) ?? undefined as unknown as string,
company: (data.company as string | null) ?? undefined,
}
}
async upsertUserOnLogin(user: AuthUser): Promise<void> {
const { error } = await supabase.from('users').upsert(
{
id: user.id,
name: user.name,
avatar_url: user.avatarUrl ?? null,
platform_role: user.platformRole,
},
{ onConflict: 'id', ignoreDuplicates: true }
)
if (error) console.error('Error al crear usuario en DB:', error)
}
Cambio 2 — src/auth/AuthContext.tsx
Agregar un useEffect([user?.id]) que sincroniza con la tabla users DESPUÉS de que el auth se estableció — fuera del lock. También necesita import { supabase } from '@/lib/supabase'.
// Después del useEffect existente que suscribe a onAuthStateChange:
// Sincronización con tabla users FUERA del lock de auth.
// Se ejecuta al cambiar de usuario (login/logout), NO en cada updateUser().
// user?.id es estable: no cambia cuando se edita nombre o empresa.
useEffect(() => {
if (!user?.id) return
;(async () => {
try {
// Upsert idempotente: crea la fila si no existe (primer login),
// ignora si ya existe (no sobreescribe ediciones posteriores).
await authService.upsertUserOnLogin(user)
// Leer company y name editado desde la tabla (pueden diferir del user_metadata de Google).
const profile = await authService.syncProfileFromDB(user.id)
if (profile) {
setUser((prev) =>
prev?.id === user.id
? {
...prev,
company: profile.company,
name: profile.name ?? prev.name,
}
: prev
)
}
} catch (err) {
console.error('Error al sincronizar perfil:', err)
}
})()
}, [user?.id]) // eslint-disable-line react-hooks/exhaustive-deps
Comportamientos garantizados:
- Login inicial → callback establece usuario básico → useEffect sincroniza con DB ✓
- Page refresh → idem ✓
- Profile save →
updateUser()crea nueva referencia perouser?.idno cambia → useEffect NO dispara → datos locales no sobreescritos ✓ - Token refresh (TOKEN_REFRESHED) → callback retorna inmediatamente → useEffect no dispara ✓
- Logout →
user?.id= null → useEffect hace early return ✓ - Re-login →
user?.idcambia → useEffect re-sincroniza ✓
Cambios en src/auth/types.ts
Agregar syncProfileFromDB y upsertUserOnLogin a la interfaz AuthService si existe, o dejar como métodos concretos en SupabaseAuthService sin agregar a la interfaz (preferible si la interfaz es abstracta).
Verificar que AuthService interface no requiera los métodos nuevos — son implementación concreta de Supabase.
Qué NO entra en esta etapa
- Cambios en ProfileSheet, LibraryPage, router.tsx
- Cambios en el sistema de simulación o heatmap
- Ninguna feature nueva
Orden de ejecución
- Modificar
SupabaseAuthService.ts:- Convertir callback de
onAuthStateChangea no-async - Eliminar upsert y select del interior del callback
- Agregar métodos
syncProfileFromDB()yupsertUserOnLogin()
- Convertir callback de
- Modificar
AuthContext.tsx:- Agregar import de
supabasesi no existe - Agregar
useEffect([user?.id])con la lógica de sincronización
- Agregar import de
npm run build— 0 errores TypeScriptnpm run test— 528+ Vitest verdesgit push origin main— obligatorio antes de validar en producción- Commit:
fix(sprint-5/etapa-4d): mover queries DB fuera de onAuthStateChange — corta deadlock lockAcquired permanente
Criterios de validación
- Guardar nombre en el Sheet → app NO se congela → nombre actualizado en el dropdown
- Guardar empresa → app NO se congela → empresa aparece en el dropdown
- Cmd+R después de guardar → los valores persisten (están en Supabase)
- Al cargar la app (page refresh) → company y name se leen correctamente desde la DB
- Primer login (cuenta nueva) → usuario creado en tabla users ✓
- Token refresh en background → no causa ningún re-render ni error
- No hay
console.errorde deadlock en la consola del browser 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