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()
|
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 signIn = useCallback(() => authService.signInWithGoogle(), [])
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
|
|||||||
@@ -32,62 +32,24 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
|
|
||||||
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
|
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
async (event, session) => {
|
(event, session) => { // NO async — el lock de GoTrueClient está activo durante este callback
|
||||||
try {
|
try {
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
// INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión
|
// 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.
|
// (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).
|
// Solo cerrar sesión en eventos explícitos (SIGNED_OUT, USER_DELETED, etc).
|
||||||
if (event !== 'INITIAL_SESSION') {
|
if (event !== 'INITIAL_SESSION') callback(null)
|
||||||
callback(null)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN') return
|
||||||
// Solo procesar eventos genuinos de inicio de sesión.
|
// Llamar callback inmediatamente con datos básicos del token OAuth.
|
||||||
// TOKEN_REFRESHED, USER_UPDATED y similares son eventos internos — ignorarlos
|
// NO hacer queries a Supabase desde aquí — el lock de auth está activo (GoTrueClient.js L3213).
|
||||||
// previene sobreescribir ediciones locales (nombre, empresa) con datos re-leídos de DB.
|
// El upsert y la lectura de company/name ocurren en AuthContext fuera del lock.
|
||||||
if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN') {
|
callback(this.buildAuthUser(session.user))
|
||||||
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)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error en onAuthStateChange:', err)
|
console.error('Error en onAuthStateChange:', err)
|
||||||
if (session?.user) {
|
if (session?.user) callback(this.buildAuthUser(session.user))
|
||||||
callback(this.buildAuthUser(session.user))
|
else if (event !== 'INITIAL_SESSION') callback(null)
|
||||||
} else if (event !== 'INITIAL_SESSION') {
|
|
||||||
callback(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -95,6 +57,36 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
return () => subscription.unsubscribe()
|
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.
|
// 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).
|
// 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 {
|
private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser {
|
||||||
|
|||||||
Reference in New Issue
Block a user