fix de guardar perfil - ahora si
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-06-20 01:31:02 -03:00
parent 496c227c88
commit a626ff040c
2 changed files with 293 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
# Sprint 5 — Etapa 4C: Fix definitivo — contención de processLock al guardar perfil
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
Etapas 13B completadas con OK formal. Etapa 4 completada con una excepción: guardar perfil congela la app indefinidamente.
Etapa 4B intentó corregirlo con dos fixes (guard en `onAuthStateChange`, useEffect de ProfileSheet con `[open]`). Esos fixes fueron insuficientes — el freeze persiste.
Etapa 4D (diagnóstico) confirmó la causa raíz real. Esta etapa la resuelve.
---
## Causa raíz confirmada
**Archivo:** `src/features/library/LibraryPage.tsx` (aprox. línea 757)
```ts
useEffect(() => {
if (!user) return
load() // ← llama getSessionSafe() → intenta adquirir processLock
}, [load, user]) // ← "user" es el objeto completo, cambia de referencia en cada update
```
**Secuencia del freeze:**
1. Usuario guarda perfil → `ProfileSheet.tsx` llama `updateUser({ name, company })`
2. `updateUser` hace `setUser(prev => ({ ...prev, name, company }))` → nueva referencia del objeto `user`
3. `LibraryPage` detecta el cambio en `user` → dispara `load()`
4. `load()` llama `getSessionSafe()` → intenta adquirir `processLock`
5. En paralelo, el `supabase.from('users').update(...)` de ProfileSheet ya tiene o está esperando `processLock` para el token refresh
6. Contención → uno de los dos queda esperando indefinidamente → freeze
**Evidencia del diagnóstico (commit dd361b1):**
Los logs en producción muestran la secuencia hasta "local state updated" y luego silencio total — exactamente el momento en que `setUser(...)` dispara el useEffect de LibraryPage.
---
## Fix — una línea
**En `src/features/library/LibraryPage.tsx`**, cambiar la dependencia del useEffect de `user` a `user?.id`:
```ts
// ANTES (fires en cada cambio de referencia del objeto user):
useEffect(() => {
if (!user) return
load()
}, [load, user])
// DESPUÉS (fires solo cuando cambia el ID — distinto usuario o logout/login):
useEffect(() => {
if (!user) return
load()
}, [load, user?.id])
```
**Por qué funciona:** `user?.id` es un primitivo estable (string). No cambia cuando `updateUser` crea un nuevo objeto con el mismo `id` pero distinto `name` o `company`. La biblioteca solo se recarga cuando hay un usuario genuinamente diferente — que es el único caso donde tiene sentido recargar.
---
## Limpieza — remover logs de diagnóstico
El commit `dd361b1` agregó `console.log` temporales en `ProfileSheet.tsx` para el diagnóstico. Removerlos todos antes del commit final.
Buscar y eliminar cualquier línea `console.log` con prefijo `[ProfileSheet]` en `src/components/ProfileSheet.tsx`.
---
## Pasos de implementación
1. Abrir `src/features/library/LibraryPage.tsx`
2. Localizar el `useEffect` con dependencia `[load, user]` que llama `load()` (aprox. línea 757)
3. Cambiar `user``user?.id` en el array de dependencias
4. Abrir `src/components/ProfileSheet.tsx`
5. Remover todos los `console.log` temporales del diagnóstico
6. `npm run build` — 0 errores TypeScript
7. `npm run test` — 528+ Vitest verdes
8. **`git push origin main`** — obligatorio antes de validar en producción (Dokploy no autodeploy)
9. Commit: `fix(sprint-5/etapa-4c): library useEffect usa user.id no user ref — corta contención processLock al guardar perfil`
---
## Qué NO entra en esta etapa
- Cambios en `processLock`, `getSessionSafe()`, ni en el sistema de auth
- Cambios en `ProfileSheet.tsx` más allá de remover los console.log
- Refactoring de `LibraryPage` más allá del fix de la dependencia
- Ninguna feature nueva
---
## Criterios de validación
- [ ] Guardar nombre en el Sheet → app no se congela → nombre actualizado en el dropdown inmediatamente
- [ ] Guardar empresa en el Sheet → app no se congela → empresa aparece en el dropdown
- [ ] Cmd+R después de guardar → los valores persisten (están en Supabase)
- [ ] Navegar a la biblioteca y volver → la lista de procesos sigue correcta
- [ ] No hay `console.log` de diagnóstico 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

View File

@@ -0,0 +1,189 @@
# 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<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,
}
}
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)
}
```
### 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