fix(sprint-5/etapa-4b): timeout AbortController en sync de perfil al login
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

upsertUserOnLogin y syncProfileFromDB adquieren el processLock de Supabase para
leer/escribir la sesión. Si el servidor GoTrue está lento (token refresh), ese
lock puede quedar ocupado varios segundos bloqueando loads de biblioteca/workspace.

Agrega AbortController con timeout de 6s a las dos queries de auth sync en
AuthContext. Si el servidor no responde a tiempo, la señal de abort cancela
el fetch, postgrest-js lanza AbortError, el lock se libera, y el IIFE termina
silenciosamente. El profile save ya usa fetch nativo (no le afecta el lock).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 02:13:53 -03:00
parent 3d3ae99125
commit 602957b6c9
2 changed files with 27 additions and 12 deletions

View File

@@ -77,25 +77,40 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// user?.id es primitivo estable: no cambia cuando se edita nombre o empresa. // user?.id es primitivo estable: no cambia cuando se edita nombre o empresa.
useEffect(() => { useEffect(() => {
if (!user?.id) return if (!user?.id) return
const userId = user.id
const capturedUser = user
// 6s timeout: si el servidor no responde, la señal de abort cancela las queries
// y libera el processLock de Supabase antes de que bloquee loads de biblioteca/workspace.
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 6000)
;(async () => { ;(async () => {
try { try {
// Upsert idempotente: crea la fila si no existe (primer login), // Upsert idempotente: crea la fila si no existe (primer login),
// ignoraDuplicates evita sobreescribir name/company editados posteriormente. // ignoreDuplicates evita sobreescribir name/company editados posteriormente.
await authService.upsertUserOnLogin(user) await authService.upsertUserOnLogin(capturedUser, controller.signal)
// Leer company y name editado desde la tabla (pueden diferir del user_metadata de Google). // Leer company y name editado desde la tabla (pueden diferir del user_metadata de Google).
const profile = await authService.syncProfileFromDB(user.id) const profile = await authService.syncProfileFromDB(userId, controller.signal)
if (profile) { if (profile) {
setUser((prev) => setUser((prev) =>
prev?.id === user.id prev?.id === userId
? { ...prev, company: profile.company, name: profile.name ?? prev.name } ? { ...prev, company: profile.company, name: profile.name ?? prev.name }
: prev : prev
) )
} }
} catch (err) { } catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
console.error('Error al sincronizar perfil:', err) console.error('Error al sincronizar perfil:', err)
} finally {
clearTimeout(timeoutId)
} }
})() })()
return () => {
controller.abort()
clearTimeout(timeoutId)
}
}, [user?.id]) // eslint-disable-line react-hooks/exhaustive-deps }, [user?.id]) // eslint-disable-line react-hooks/exhaustive-deps
const signIn = useCallback(() => authService.signInWithGoogle(), []) const signIn = useCallback(() => authService.signInWithGoogle(), [])

View File

@@ -59,12 +59,10 @@ export class SupabaseAuthService implements AuthService {
// Leer company y name editados desde la tabla users (pueden diferir del user_metadata de Google). // 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. // Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal.
async syncProfileFromDB(userId: string): Promise<Pick<AuthUser, 'name' | 'company'> | null> { // signal permite cancelar si el servidor tarda demasiado (libera el processLock).
const { data } = await supabase async syncProfileFromDB(userId: string, signal?: AbortSignal): Promise<Pick<AuthUser, 'name' | 'company'> | null> {
.from('users') const base = supabase.from('users').select('company, name').eq('id', userId)
.select('company, name') const { data } = await (signal ? base.abortSignal(signal) : base).maybeSingle()
.eq('id', userId)
.maybeSingle()
if (!data) return null if (!data) return null
return { return {
name: (data.name as string | null) ?? undefined as unknown as string, name: (data.name as string | null) ?? undefined as unknown as string,
@@ -74,8 +72,9 @@ export class SupabaseAuthService implements AuthService {
// Upsert idempotente al login: crea la fila si no existe, ignora si ya existe. // 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. // Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal.
async upsertUserOnLogin(user: AuthUser): Promise<void> { // signal permite cancelar si el servidor tarda demasiado (libera el processLock).
const { error } = await supabase.from('users').upsert( async upsertUserOnLogin(user: AuthUser, signal?: AbortSignal): Promise<void> {
const builder = supabase.from('users').upsert(
{ {
id: user.id, id: user.id,
name: user.name, name: user.name,
@@ -84,6 +83,7 @@ export class SupabaseAuthService implements AuthService {
}, },
{ onConflict: 'id', ignoreDuplicates: true } { onConflict: 'id', ignoreDuplicates: true }
) )
const { error } = await (signal ? builder.abortSignal(signal) : builder)
if (error) console.error('Error al crear usuario en DB:', error) if (error) console.error('Error al crear usuario en DB:', error)
} }