From 496c227c8831d45c7cc00627f2c48c0d01a479f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Sat, 20 Jun 2026 01:28:26 -0300 Subject: [PATCH] =?UTF-8?q?fix(sprint-5/etapa-4d):=20mover=20queries=20DB?= =?UTF-8?q?=20fuera=20de=20onAuthStateChange=20=E2=80=94=20corta=20deadloc?= =?UTF-8?q?k=20lockAcquired=20permanente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El callback de onAuthStateChange en GoTrueClient adquiere un lock interno (lockAcquired=true). Hacer await supabase.from('users').select() dentro de ese callback genera un deadlock circular: el select necesita getSession() → _acquireLock() → lockAcquired=true → espera al callback → callback espera al select → deadlock permanente. Toda query posterior a Supabase cuelga. Fix: el callback de onAuthStateChange pasa a ser síncrono y solo llama buildAuthUser() (sin queries). El upsert y la lectura de company/name se mueven a un useEffect([user?.id]) en AuthContext, que corre FUERA del lock de auth y solo se dispara al cambiar de usuario real (distinto id), no en cada updateUser() local. Co-Authored-By: Claude Sonnet 4.6 --- src/auth/AuthContext.tsx | 26 ++++++++++ src/auth/SupabaseAuthService.ts | 86 +++++++++++++++------------------ 2 files changed, 65 insertions(+), 47 deletions(-) diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index 5fe64d0..d18cc78 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -72,6 +72,32 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => unsubscribe() }, []) + // 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 primitivo 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), + // ignoraDuplicates evita sobreescribir name/company editados posteriormente. + 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 + const signIn = useCallback(() => authService.signInWithGoogle(), []) const signOut = useCallback(async () => { diff --git a/src/auth/SupabaseAuthService.ts b/src/auth/SupabaseAuthService.ts index 1b8f1ba..73172d7 100644 --- a/src/auth/SupabaseAuthService.ts +++ b/src/auth/SupabaseAuthService.ts @@ -32,62 +32,24 @@ export class SupabaseAuthService implements AuthService { onAuthStateChange(callback: (user: AuthUser | null) => void): () => void { const { data: { subscription } } = supabase.auth.onAuthStateChange( - async (event, session) => { + (event, session) => { // NO async — el lock de GoTrueClient está activo durante este callback try { if (!session?.user) { // INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión // (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout. // Solo cerrar sesión en eventos explícitos (SIGNED_OUT, USER_DELETED, etc). - if (event !== 'INITIAL_SESSION') { - callback(null) - } + if (event !== 'INITIAL_SESSION') callback(null) return } - - // Solo procesar eventos genuinos de inicio de sesión. - // TOKEN_REFRESHED, USER_UPDATED y similares son eventos internos — ignorarlos - // previene sobreescribir ediciones locales (nombre, empresa) con datos re-leídos de DB. - if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN') { - return - } - - if (event === 'SIGNED_IN') { - // Primer login: upsert del usuario en la tabla users - const email = session.user.email ?? '' - const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest' - const { error: upsertError } = await supabase.from('users').upsert( - { - id: session.user.id, - name: session.user.user_metadata?.full_name ?? email, - avatar_url: session.user.user_metadata?.avatar_url ?? null, - platform_role: role, - }, - { onConflict: 'id', ignoreDuplicates: true } - ) - if (upsertError) console.error('Error al crear usuario en DB:', upsertError) - } - - // Leer company y name editado desde la tabla users (pueden diferir del user_metadata de Google) - const { data: userRow } = await supabase - .from('users') - .select('company, name') - .eq('id', session.user.id) - .maybeSingle() - - const base = this.buildAuthUser(session.user) - const user: AuthUser = { - ...base, - company: (userRow?.company as string | null) ?? undefined, - name: (userRow?.name as string | null) ?? base.name, - } - callback(user) + 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 (GoTrueClient.js L3213). + // El upsert y la lectura de company/name ocurren en AuthContext fuera del lock. + 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) - } + if (session?.user) callback(this.buildAuthUser(session.user)) + else if (event !== 'INITIAL_SESSION') callback(null) } } ) @@ -95,6 +57,36 @@ export class SupabaseAuthService implements AuthService { return () => subscription.unsubscribe() } + // Leer company y name editados desde la tabla users (pueden diferir del user_metadata de Google). + // Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal. + 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, + } + } + + // Upsert idempotente al login: crea la fila si no existe, ignora si ya existe. + // Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal. + 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) + } + // Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB. // El rol se deriva del dominio del email (misma lógica que el upsert en SIGNED_IN). private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record }): AuthUser {