fix(sprint-5): optimistic update en ProfileSheet — cierra Sheet antes del fetch, UI nunca bloqueada
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-06-20 19:35:04 -03:00
parent 27ce96ca1e
commit 69f744ae3f

View File

@@ -32,66 +32,56 @@ export function ProfileSheet({ open, onOpenChange }: ProfileSheetProps) {
const { user, updateUser } = useAuth() const { user, updateUser } = useAuth()
const [name, setName] = useState('') const [name, setName] = useState('')
const [company, setCompany] = useState('') const [company, setCompany] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (open && user) { if (open && user) {
setName(user.name || user.email || '') setName(user.name || user.email || '')
setCompany(user.company ?? '') setCompany(user.company ?? '')
setError(null)
} }
// Intencional: solo inicializar cuando el sheet se abre, no en cada cambio de `user` // Intencional: solo inicializar cuando el sheet se abre, no en cada cambio de `user`
// (evita resetear el formulario mientras el usuario está editando). // (evita resetear el formulario mientras el usuario está editando).
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]) }, [open])
async function handleSave() { function handleSave() {
if (!user || !name.trim()) return if (!user || !name.trim()) return
setSaving(true)
setError(null)
try {
// Usamos fetch nativo en lugar de supabase.from().update() para evitar que esta
// operación quede en la cola del processLock compartido. En el login, upsertUserOnLogin
// y syncProfileFromDB adquieren ese lock; si el servidor tarda, la cola queda ocupada
// y el save cuelga indefinidamente. El fetch nativo es independiente del lock.
const token = getAccessToken()
if (!token) throw new Error('Sesión no disponible — recargá la página e intentá de nuevo')
const res = await fetch( const savedName = name.trim()
`${SUPABASE_URL}/rest/v1/users?id=eq.${encodeURIComponent(user.id)}`, const savedCompany = company.trim() || undefined
{
method: 'PATCH',
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
name: name.trim(),
company: company.trim() || null,
}),
// Timeout de 8s: si Supabase no responde (cold start, red lenta, etc.),
// el catch muestra un mensaje de error y libera el botón.
// Sin esto, la modal queda abierta indefinidamente bloqueando todo el UI.
signal: AbortSignal.timeout(8000),
}
)
if (!res.ok) { // Actualización optimista: aplicar cambios localmente y cerrar el Sheet de inmediato.
const detail = await res.text().catch(() => '') // El Sheet nunca queda abierto esperando la respuesta del servidor.
throw new Error(`Error ${res.status}${detail ? `: ${detail}` : ''}`) // La modal de Radix bloquea todo el UI mientras está abierta, así que cerramos
// ANTES del await para que el usuario pueda seguir trabajando sin demora.
updateUser({ name: savedName, company: savedCompany })
onOpenChange(false)
// Persistir en Supabase en background — fire & forget.
// Si falla (red lenta, token vencido, cold start de Supabase), el estado local
// ya tiene el nuevo nombre. En el próximo login, syncProfileFromDB recupera el DB.
const token = getAccessToken()
if (!token) return
void fetch(
`${SUPABASE_URL}/rest/v1/users?id=eq.${encodeURIComponent(user.id)}`,
{
method: 'PATCH',
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({ name: savedName, company: savedCompany ?? null }),
signal: AbortSignal.timeout(10000),
} }
)
updateUser({ name: name.trim(), company: company.trim() || undefined }) .then((res) => {
onOpenChange(false) if (!res.ok) console.error('[ProfileSheet] Error guardando perfil:', res.status)
} catch (err) { })
console.error('Error al guardar perfil:', err) .catch((err) => {
setError(err instanceof Error ? err.message : 'Error al guardar perfil') console.error('[ProfileSheet] Error guardando perfil:', err)
} finally { })
setSaving(false)
}
} }
return ( return (
@@ -125,17 +115,13 @@ export function ProfileSheet({ open, onOpenChange }: ProfileSheetProps) {
<Label className="text-xs">Email</Label> <Label className="text-xs">Email</Label>
<Input value={user?.email ?? ''} readOnly className="bg-muted text-muted-foreground cursor-not-allowed" /> <Input value={user?.email ?? ''} readOnly className="bg-muted text-muted-foreground cursor-not-allowed" />
</div> </div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
</div> </div>
<SheetFooter className="mt-6 flex gap-2"> <SheetFooter className="mt-6 flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleSave} disabled={!name.trim() || saving} className="gap-1.5"> <Button onClick={handleSave} disabled={!name.trim()} className="gap-1.5">
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
Guardar Guardar
</Button> </Button>