fix(sprint-5/etapa-4d): mover queries DB fuera de onAuthStateChange — corta deadlock lockAcquired permanente
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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<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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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<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)
|
||||
}
|
||||
|
||||
// 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<string, unknown> }): AuthUser {
|
||||
|
||||
Reference in New Issue
Block a user