From 3621951f2aeceebaff70328ffe8a6c91e9bad83d Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:21:57 -0700 Subject: [PATCH] perf: throttle scroll handlers with requestAnimationFrame Wrap scroll handlers in thread.tsx, InboxSidebar.tsx, and DocumentsTableShell.tsx with requestAnimationFrame batching so scroll position state updates fire at most once per animation frame instead of on every scroll event (up to 60/sec at 60fps). Add cleanup useEffect to cancel pending frames on unmount. Fixes #1103 --- .../(manage)/components/DocumentsTableShell.tsx | 12 +++++++++--- surfsense_web/components/assistant-ui/thread.tsx | 12 +++++++++--- .../components/layout/ui/sidebar/InboxSidebar.tsx | 12 +++++++++--- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index ceef9f2e1..dc8966571 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -267,12 +267,18 @@ export function DocumentsTableShell({ const [metadataJson, setMetadataJson] = useState | null>(null); const [metadataLoading, setMetadataLoading] = useState(false); const [previewScrollPos, setPreviewScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const previewRafRef = useRef(); const handlePreviewScroll = useCallback((e: React.UIEvent) => { const el = e.currentTarget; - const atTop = el.scrollTop <= 2; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; - setPreviewScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + if (previewRafRef.current) return; + previewRafRef.current = requestAnimationFrame(() => { + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setPreviewScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + previewRafRef.current = undefined; + }); }, []); + useEffect(() => () => { if (previewRafRef.current) cancelAnimationFrame(previewRafRef.current); }, []); const [deleteDoc, setDeleteDoc] = useState(null); const [isDeleting, setIsDeleting] = useState(false); diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 0d0163d8a..0f230cec3 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -816,12 +816,18 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false const isDesktop = useMediaQuery("(min-width: 640px)"); const { openDialog: openUploadDialog } = useDocumentUploadDialog(); const [toolsScrollPos, setToolsScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const toolsRafRef = useRef(); const handleToolsScroll = useCallback((e: React.UIEvent) => { const el = e.currentTarget; - const atTop = el.scrollTop <= 2; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; - setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + if (toolsRafRef.current) return; + toolsRafRef.current = requestAnimationFrame(() => { + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setToolsScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + toolsRafRef.current = undefined; + }); }, []); + useEffect(() => () => { if (toolsRafRef.current) cancelAnimationFrame(toolsRafRef.current); }, []); const isComposerTextEmpty = useAuiState(({ composer }) => { const text = composer.text?.trim() || ""; return text.length === 0; diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 72400a589..4aa8d4c60 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -178,12 +178,18 @@ export function InboxSidebarContent({ const [mounted, setMounted] = useState(false); const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const connectorRafRef = useRef(); const handleConnectorScroll = useCallback((e: React.UIEvent) => { const el = e.currentTarget; - const atTop = el.scrollTop <= 2; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; - setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + if (connectorRafRef.current) return; + connectorRafRef.current = requestAnimationFrame(() => { + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + connectorRafRef.current = undefined; + }); }, []); + useEffect(() => () => { if (connectorRafRef.current) cancelAnimationFrame(connectorRafRef.current); }, []); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [markingAsReadId, setMarkingAsReadId] = useState(null);