From 2fb41e0cf4b2624687918a7cc59dfad637726c1f Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 5 Mar 2026 16:54:28 +0800 Subject: [PATCH] Fix toast accept stale closure + harden backend error responses Toast accept button captured a stale `respond` reference from the Sonner closure. Use respondRef pattern so clicks always dispatch through the current mutation. Backend respond endpoint now catches unhandled exceptions and returns proper JSON with detail field instead of plain-text 500s. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/connections.py | 20 +++++++++++++++++++ .../notifications/NotificationToaster.tsx | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py index 430c015..2f90b3b 100644 --- a/backend/app/routers/connections.py +++ b/backend/app/routers/connections.py @@ -9,6 +9,7 @@ Security: - Audit logging for all connection events """ import asyncio +import logging from datetime import date as date_type, datetime, timedelta, timezone from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request @@ -49,6 +50,7 @@ from app.services.connection import ( from app.services.notification import create_notification router = APIRouter() +logger = logging.getLogger(__name__) # ── Helpers ────────────────────────────────────────────────────────── @@ -355,6 +357,24 @@ async def respond_to_request( current_user: User = Depends(get_current_user), ): """Accept or reject a connection request. Atomic via UPDATE...WHERE status='pending'.""" + try: + return await _respond_to_request_inner(body, request, background_tasks, request_id, db, current_user) + except HTTPException: + raise + except Exception: + # get_db middleware auto-rollbacks on unhandled exceptions + logger.exception("Unhandled error in respond_to_request (request_id=%s, user=%s)", request_id, current_user.id) + raise HTTPException(status_code=500, detail=f"Internal server error while processing connection response (request {request_id})") + + +async def _respond_to_request_inner( + body: RespondRequest, + request: Request, + background_tasks: BackgroundTasks, + request_id: int, + db: AsyncSession, + current_user: User, +) -> RespondAcceptResponse | RespondRejectResponse: now = datetime.now() # Atomic update — only succeeds if status is still 'pending' and receiver is current user diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 4ebd234..53f6d0c 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -16,6 +16,9 @@ export default function NotificationToaster() { const prevUnreadRef = useRef(0); // Track in-flight request IDs so repeated clicks are blocked const respondingRef = useRef>(new Set()); + // Always call the latest respond — Sonner toasts capture closures at creation time + const respondRef = useRef(respond); + respondRef.current = respond; const handleConnectionRespond = useCallback( async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => { @@ -30,7 +33,7 @@ export default function NotificationToaster() { ); try { - await respond({ requestId, action }); + await respondRef.current({ requestId, action }); toast.dismiss(loadingId); toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); } catch (err) { @@ -40,7 +43,7 @@ export default function NotificationToaster() { respondingRef.current.delete(requestId); } }, - [respond], + [], ); // Track unread count changes to force-refetch the list