# 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 `async` function and it runs synchronously during the processing of the changes causing the event. You can easily create a dead-lock by using `await` on 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))`: ```ts 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): ```ts async syncProfileFromDB(userId: string): Promise | 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 { 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'`. ```tsx // 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 pero `user?.id` no 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?.id` cambia → 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 1. Modificar `SupabaseAuthService.ts`: - Convertir callback de `onAuthStateChange` a no-async - Eliminar upsert y select del interior del callback - Agregar métodos `syncProfileFromDB()` y `upsertUserOnLogin()` 2. Modificar `AuthContext.tsx`: - Agregar import de `supabase` si no existe - Agregar `useEffect([user?.id])` con la lógica de sincronización 3. `npm run build` — 0 errores TypeScript 4. `npm run test` — 528+ Vitest verdes 5. **`git push origin main`** — obligatorio antes de validar en producción 6. 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.error` de deadlock en la consola del browser - [ ] `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