diff --git a/sprints/sprint-5/ETAPA_4C_PROMPT.md b/sprints/sprint-5/ETAPA_4C_PROMPT.md new file mode 100644 index 0000000..86a820f --- /dev/null +++ b/sprints/sprint-5/ETAPA_4C_PROMPT.md @@ -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 1–3B 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 diff --git a/sprints/sprint-5/ETAPA_4D_PROMPT.md b/sprints/sprint-5/ETAPA_4D_PROMPT.md new file mode 100644 index 0000000..be959c7 --- /dev/null +++ b/sprints/sprint-5/ETAPA_4D_PROMPT.md @@ -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 | 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 { + 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