From b0e2f1803b0fb3179dad25eadd1b660a72532ff3 Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 6 Aug 2026 20:22:36 +0300 Subject: [PATCH] refactor(frontend): optimize high-frequency interactions --- frontend/src/app/messages/[chatId]/page.tsx | 15 +- frontend/src/app/tasks/page.tsx | 97 ++------- .../components/requests/SwipeApprovalMode.tsx | 47 ++++- .../components/shared/SearchableSelect.tsx | 106 +++++----- frontend/src/hooks/useChatScroll.ts | 31 +-- frontend/src/hooks/useTaskBoardScroll.ts | 184 ++++++++++++++++++ 6 files changed, 330 insertions(+), 150 deletions(-) create mode 100644 frontend/src/hooks/useTaskBoardScroll.ts diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index cd04479f..c7325708 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -584,7 +584,9 @@ function MessageDialogPageContent() { if (!c?.containerRef?.current) return; const viewport = c.containerRef.current; - const onScroll = () => { + let frameId: number | null = null; + const processScrollPosition = () => { + frameId = null; if (!cm.loadingOlder && !cm.messagesLoading && (cm.hasMoreOlder || cm.allowOneOlderProbe) && viewport.scrollTop <= 120) { cm.loadOlderMessages(); } @@ -603,8 +605,17 @@ function MessageDialogPageContent() { } }; + const onScroll = () => { + if (frameId === null) { + frameId = window.requestAnimationFrame(processScrollPosition); + } + }; + viewport.addEventListener("scroll", onScroll, { passive: true }); - return () => viewport.removeEventListener("scroll", onScroll); + return () => { + viewport.removeEventListener("scroll", onScroll); + if (frameId !== null) window.cancelAnimationFrame(frameId); + }; }, [cm, markRead, scroll]); useEffect(() => { diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 295368f2..9a601bcd 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -68,7 +68,6 @@ import { useMemo, useRef, useState, - type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject, } from "react"; @@ -84,6 +83,7 @@ import { CommentComposer, CommentDeleteButton } from "@/components/shared/Commen import { Modal } from "@/components/ui"; import { useNotifications } from "@/contexts/NotificationsContext"; import { useUser } from "@/contexts/UserContext"; +import { useTaskBoardScroll } from "@/hooks/useTaskBoardScroll"; import { useTaskBoardRealtime } from "@/hooks/useTaskBoardRealtime"; import { apiClient } from "@/lib/api"; import { displayUserName, formatDate, formatDateTime, formatMoney } from "@/lib/shared"; @@ -1340,7 +1340,7 @@ function BoardTaskCell({
(null); const boardViewStateRef = useRef(EMPTY_BOARD_VIEW_STATE); const pendingBoardViewStateRef = useRef>(new Map()); - const boardScrollRef = useRef(null); - const boardPanRef = useRef<{ - pointerId: number; - startX: number; - startY: number; - scrollLeft: number; - scrollTop: number; - } | null>(null); const columnNodeRefs = useRef>(new Map()); const taskAttachmentInputRef = useRef(null); const taskFormAttachmentInputRef = useRef(null); @@ -3412,8 +3404,13 @@ function TasksPageContent() { const taskFormResourcesRequestRef = useRef(0); const readingBoardNotificationsRef = useRef>(new Set()); const initialLoadCompleteRef = useRef(false); - const [boardPanning, setBoardPanning] = useState(false); - const [boardViewportHeight, setBoardViewportHeight] = useState(0); + const { + boardScrollRef, + finishBoardPan, + moveBoardPan, + startBoardPan, + viewportHeight: boardViewportHeight, + } = useTaskBoardScroll({ board, desktopWideMode, loading }); const sensors = useSensors( useSensor(PointerSensor, { @@ -3421,32 +3418,6 @@ function TasksPageContent() { }), ); - useEffect(() => { - const container = boardScrollRef.current; - if (!container || loading) return undefined; - - const updateBoardViewport = () => { - setBoardViewportHeight(container.clientHeight); - container.querySelectorAll(".tasks-board-column-group").forEach((group) => { - const content = group.querySelector(".tasks-board-column-content"); - const stickyHeader = group.querySelector(".tasks-board-group-header-sticky"); - const contentBottomReached = content && stickyHeader - ? content.getBoundingClientRect().bottom <= stickyHeader.getBoundingClientRect().bottom + 1 - : false; - group.dataset.contentBottomReached = contentBottomReached ? "true" : "false"; - }); - }; - updateBoardViewport(); - - container.addEventListener("scroll", updateBoardViewport, { passive: true }); - const observer = new ResizeObserver(updateBoardViewport); - observer.observe(container); - return () => { - container.removeEventListener("scroll", updateBoardViewport); - observer.disconnect(); - }; - }, [board?.id, desktopWideMode, loading]); - useEffect(() => { if ( typeof window !== "undefined" && @@ -3828,7 +3799,7 @@ function TasksPageContent() { const scrollBoardToStart = useCallback(() => { boardScrollRef.current?.scrollTo({ left: 0, behavior: "smooth" }); - }, []); + }, [boardScrollRef]); const scrollToColumn = useCallback((columnId: number) => { const container = boardScrollRef.current; @@ -3843,7 +3814,7 @@ function TasksPageContent() { left: Math.max(0, targetLeft), behavior: "smooth", }); - }, []); + }, [boardScrollRef]); const focusAllColumns = useCallback(() => { setSelectedColumnTarget("all"); @@ -3855,47 +3826,6 @@ function TasksPageContent() { window.requestAnimationFrame(() => scrollToColumn(columnId)); }, [scrollToColumn]); - const startBoardPan = useCallback((event: ReactPointerEvent) => { - if (event.button !== 1) return; - const container = boardScrollRef.current; - if (!container) return; - - event.preventDefault(); - event.stopPropagation(); - boardPanRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - scrollLeft: container.scrollLeft, - scrollTop: container.scrollTop, - }; - container.setPointerCapture(event.pointerId); - setBoardPanning(true); - }, []); - - const moveBoardPan = useCallback((event: ReactPointerEvent) => { - const pan = boardPanRef.current; - const container = boardScrollRef.current; - if (!pan || !container || pan.pointerId !== event.pointerId) return; - - event.preventDefault(); - event.stopPropagation(); - container.scrollLeft = pan.scrollLeft - (event.clientX - pan.startX); - container.scrollTop = pan.scrollTop - (event.clientY - pan.startY); - }, []); - - const finishBoardPan = useCallback((event: ReactPointerEvent) => { - const pan = boardPanRef.current; - const container = boardScrollRef.current; - if (!pan || pan.pointerId !== event.pointerId) return; - - if (container?.hasPointerCapture(event.pointerId)) { - container.releasePointerCapture(event.pointerId); - } - boardPanRef.current = null; - setBoardPanning(false); - }, []); - const syncTaskBoardState = useCallback(async (eventBoardId?: number | null) => { const currentBoardId = selectedBoardIdRef.current; try { @@ -6934,12 +6864,13 @@ function TasksPageContent() { onPointerMoveCapture={moveBoardPan} onPointerUpCapture={finishBoardPan} onPointerCancelCapture={finishBoardPan} + onLostPointerCapture={finishBoardPan} onAuxClick={(event) => { if (event.button === 1) event.preventDefault(); }} className={`tasks-board-scroll min-w-0 overflow-hidden pb-3 xl:overflow-auto ${ - boardPanning ? "cursor-grabbing select-none" : "" - } ${desktopWideMode ? "lg:min-h-0 lg:flex-1" : "xl:min-h-0 xl:flex-1"}`} + desktopWideMode ? "lg:min-h-0 lg:flex-1" : "xl:min-h-0 xl:flex-1" + }`} >
{topLevelColumns.map((column) => { diff --git a/frontend/src/components/requests/SwipeApprovalMode.tsx b/frontend/src/components/requests/SwipeApprovalMode.tsx index 291029e2..54419e3c 100644 --- a/frontend/src/components/requests/SwipeApprovalMode.tsx +++ b/frontend/src/components/requests/SwipeApprovalMode.tsx @@ -54,6 +54,8 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo const [dragY, setDragY] = useState(0); const [dragging, setDragging] = useState(false); const startPos = useRef({ x: 0, y: 0 }); + const pendingDrag = useRef({ x: 0, y: 0 }); + const dragFrame = useRef(null); const cardRef = useRef(null); const [flyOut, setFlyOut] = useState<"left" | "right" | "up" | null>(null); @@ -64,6 +66,18 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo const current = queue[0] || null; + const applyPendingDrag = useCallback(() => { + dragFrame.current = null; + setDragX(pendingDrag.current.x); + setDragY(pendingDrag.current.y); + }, []); + + useEffect(() => () => { + if (dragFrame.current !== null) { + window.cancelAnimationFrame(dragFrame.current); + } + }, []); + /* ── perform action ── */ const performAction = useCallback( @@ -89,6 +103,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo // error is handled by parent (actionError state) } finally { setBusy(false); + pendingDrag.current = { x: 0, y: 0 }; setDragX(0); setDragY(0); setFlyOut(null); @@ -118,6 +133,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo (e: React.PointerEvent) => { if (busy || detailOpen) return; startPos.current = { x: e.clientX, y: e.clientY }; + pendingDrag.current = { x: 0, y: 0 }; setDragging(true); (e.target as HTMLElement).setPointerCapture?.(e.pointerId); }, @@ -127,30 +143,45 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo const onPointerMove = useCallback( (e: React.PointerEvent) => { if (!dragging) return; - setDragX(e.clientX - startPos.current.x); - setDragY(e.clientY - startPos.current.y); + pendingDrag.current = { + x: e.clientX - startPos.current.x, + y: e.clientY - startPos.current.y, + }; + if (dragFrame.current === null) { + dragFrame.current = window.requestAnimationFrame(applyPendingDrag); + } }, - [dragging] + [applyPendingDrag, dragging] ); - const onPointerUp = useCallback(() => { + const onPointerUp = useCallback((e: React.PointerEvent) => { if (!dragging) return; + const finalDrag = { + x: e.clientX - startPos.current.x, + y: e.clientY - startPos.current.y, + }; + pendingDrag.current = finalDrag; + if (dragFrame.current !== null) { + window.cancelAnimationFrame(dragFrame.current); + dragFrame.current = null; + } setDragging(false); - if (dragX > SWIPE_THRESHOLD) { + if (finalDrag.x > SWIPE_THRESHOLD) { setFlyOut("right"); setTimeout(() => performAction("approved"), 250); - } else if (dragX < -SWIPE_THRESHOLD) { + } else if (finalDrag.x < -SWIPE_THRESHOLD) { setFlyOut("left"); setTimeout(() => performAction("rejected"), 250); - } else if (dragY < -SWIPE_THRESHOLD) { + } else if (finalDrag.y < -SWIPE_THRESHOLD) { setFlyOut("up"); setTimeout(() => performAction("skipped"), 250); } else { + pendingDrag.current = { x: 0, y: 0 }; setDragX(0); setDragY(0); } - }, [dragging, dragX, dragY, performAction]); + }, [dragging, performAction]); /* ── keyboard ── */ useEffect(() => { diff --git a/frontend/src/components/shared/SearchableSelect.tsx b/frontend/src/components/shared/SearchableSelect.tsx index c73841ce..5a33bf03 100644 --- a/frontend/src/components/shared/SearchableSelect.tsx +++ b/frontend/src/components/shared/SearchableSelect.tsx @@ -1,8 +1,8 @@ "use client"; import { ChevronDown } from "lucide-react"; -import type { ReactNode } from "react"; -import { useEffect, useRef, useState } from "react"; +import type { ReactNode, RefObject } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; type DropdownPosition = { @@ -31,6 +31,56 @@ function getDropdownPosition(anchor: HTMLElement): DropdownPosition { }; } +function dropdownPositionsEqual( + current: DropdownPosition | null, + next: DropdownPosition, +) { + return current?.left === next.left + && current.maxHeight === next.maxHeight + && current.top === next.top + && current.width === next.width; +} + +function useDropdownPosition( + open: boolean, + anchorRef: RefObject, +) { + const [position, setPosition] = useState(null); + + const updatePosition = useCallback(() => { + const anchor = anchorRef.current; + if (!anchor) return; + const nextPosition = getDropdownPosition(anchor); + setPosition((currentPosition) => ( + dropdownPositionsEqual(currentPosition, nextPosition) ? currentPosition : nextPosition + )); + }, [anchorRef]); + + useEffect(() => { + if (!open) return undefined; + + let frameId: number | null = null; + const schedulePositionUpdate = () => { + if (frameId !== null) return; + frameId = window.requestAnimationFrame(() => { + frameId = null; + updatePosition(); + }); + }; + + updatePosition(); + window.addEventListener("resize", schedulePositionUpdate); + window.addEventListener("scroll", schedulePositionUpdate, true); + return () => { + window.removeEventListener("resize", schedulePositionUpdate); + window.removeEventListener("scroll", schedulePositionUpdate, true); + if (frameId !== null) window.cancelAnimationFrame(frameId); + }; + }, [open, updatePosition]); + + return { position, updatePosition }; +} + /* ── Single-select dropdown with search ── */ export function SearchableSelectSingle({ label, @@ -49,9 +99,12 @@ export function SearchableSelectSingle({ }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); - const [dropdownPosition, setDropdownPosition] = useState(null); const ref = useRef(null); const menuRef = useRef(null); + const { + position: dropdownPosition, + updatePosition: updateDropdownPosition, + } = useDropdownPosition(open, ref); useEffect(() => { const handler = (e: MouseEvent) => { @@ -63,29 +116,10 @@ export function SearchableSelectSingle({ return () => document.removeEventListener("mousedown", handler); }, []); - useEffect(() => { - if (!open) return; - - const updatePosition = () => { - if (ref.current) { - setDropdownPosition(getDropdownPosition(ref.current)); - } - }; - - window.addEventListener("resize", updatePosition); - window.addEventListener("scroll", updatePosition, true); - return () => { - window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition, true); - }; - }, [open]); - const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase())); const selectedName = items.find((i) => i.id === selectedId)?.name; const toggleOpen = () => { - if (!open && ref.current) { - setDropdownPosition(getDropdownPosition(ref.current)); - } + if (!open) updateDropdownPosition(); setOpen((value) => !value); }; @@ -180,9 +214,12 @@ export function SearchableSelectMulti({ }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); - const [dropdownPosition, setDropdownPosition] = useState(null); const ref = useRef(null); const menuRef = useRef(null); + const { + position: dropdownPosition, + updatePosition: updateDropdownPosition, + } = useDropdownPosition(open, ref); useEffect(() => { const handler = (e: MouseEvent) => { @@ -194,31 +231,12 @@ export function SearchableSelectMulti({ return () => document.removeEventListener("mousedown", handler); }, []); - useEffect(() => { - if (!open) return; - - const updatePosition = () => { - if (ref.current) { - setDropdownPosition(getDropdownPosition(ref.current)); - } - }; - - window.addEventListener("resize", updatePosition); - window.addEventListener("scroll", updatePosition, true); - return () => { - window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition, true); - }; - }, [open]); - const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase())); const selectedItems = items.filter((i) => selectedIds.includes(i.id)); const selectedNames = selectedItems.map((i) => i.name); const isInline = layout === "inline"; const toggleOpen = () => { - if (!open && ref.current) { - setDropdownPosition(getDropdownPosition(ref.current)); - } + if (!open) updateDropdownPosition(); setOpen((value) => !value); }; diff --git a/frontend/src/hooks/useChatScroll.ts b/frontend/src/hooks/useChatScroll.ts index ea875a65..ceed460c 100644 --- a/frontend/src/hooks/useChatScroll.ts +++ b/frontend/src/hooks/useChatScroll.ts @@ -76,13 +76,6 @@ export function useChatScroll({ const floatingDateTimeoutRef = useRef(null); - // Sync selected date from floating date when navigator is closed - useEffect(() => { - if (floatingDateValue && !isDateNavigatorOpen) { - setSelectedHistoryDate(floatingDateValue); - } - }, [floatingDateValue, isDateNavigatorOpen]); - // Cleanup useEffect(() => () => { if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current); @@ -144,6 +137,9 @@ export function useChatScroll({ setFloatingDate(topDate); setFloatingDateValue(topDateVal); setShowFloatingDate(true); + if (topDateVal && !isDateNavigatorOpen) { + setSelectedHistoryDate(topDateVal); + } if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current); floatingDateTimeoutRef.current = setTimeout(() => setShowFloatingDate(false), 1100); @@ -153,7 +149,7 @@ export function useChatScroll({ setFloatingDate(null); setFloatingDateValue(null); setShowFloatingDate(false); - }, []); + }, [isDateNavigatorOpen]); // Expose updateFloatingDate as a stable ref so the scroll handler can call it const updateFloatingDateRef = useRef(updateFloatingDate); @@ -166,13 +162,22 @@ export function useChatScroll({ if (!c?.containerRef?.current) return; const viewport = c.containerRef.current; - const onScroll = () => updateFloatingDateRef.current(viewport); - const frameId = window.requestAnimationFrame(onScroll); + let frameId: number | null = null; + const updateFromScrollPosition = () => { + frameId = null; + updateFloatingDateRef.current(viewport); + }; + const scheduleUpdate = () => { + if (frameId === null) { + frameId = window.requestAnimationFrame(updateFromScrollPosition); + } + }; - viewport.addEventListener("scroll", onScroll, { passive: true }); + scheduleUpdate(); + viewport.addEventListener("scroll", scheduleUpdate, { passive: true }); return () => { - window.cancelAnimationFrame(frameId); - viewport.removeEventListener("scroll", onScroll); + if (frameId !== null) window.cancelAnimationFrame(frameId); + viewport.removeEventListener("scroll", scheduleUpdate); }; }, [chatId, messageCount, viewportRef]); diff --git a/frontend/src/hooks/useTaskBoardScroll.ts b/frontend/src/hooks/useTaskBoardScroll.ts new file mode 100644 index 00000000..c28bba95 --- /dev/null +++ b/frontend/src/hooks/useTaskBoardScroll.ts @@ -0,0 +1,184 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; + +import type { TaskBoard } from "@/types/api"; + +type BoardPanState = { + pointerId: number; + startX: number; + startY: number; + currentX: number; + currentY: number; + scrollLeft: number; + scrollTop: number; + frameId: number | null; +}; + +type UseTaskBoardScrollOptions = { + board: TaskBoard | null; + desktopWideMode: boolean; + loading: boolean; +}; + +export function useTaskBoardScroll({ + board, + desktopWideMode, + loading, +}: UseTaskBoardScrollOptions) { + const boardScrollRef = useRef(null); + const boardPanRef = useRef(null); + const [viewportHeight, setViewportHeight] = useState(0); + + const applyBoardPan = useCallback(() => { + const pan = boardPanRef.current; + const container = boardScrollRef.current; + if (!pan || !container) return; + + pan.frameId = null; + container.scrollLeft = pan.scrollLeft - (pan.currentX - pan.startX); + container.scrollTop = pan.scrollTop - (pan.currentY - pan.startY); + }, []); + + const startBoardPan = useCallback((event: ReactPointerEvent) => { + if (event.button !== 1) return; + const container = boardScrollRef.current; + if (!container) return; + + event.preventDefault(); + event.stopPropagation(); + boardPanRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + currentX: event.clientX, + currentY: event.clientY, + scrollLeft: container.scrollLeft, + scrollTop: container.scrollTop, + frameId: null, + }; + container.setPointerCapture(event.pointerId); + container.classList.add("cursor-grabbing", "select-none"); + }, []); + + const moveBoardPan = useCallback((event: ReactPointerEvent) => { + const pan = boardPanRef.current; + if (!pan || pan.pointerId !== event.pointerId) return; + + event.preventDefault(); + event.stopPropagation(); + pan.currentX = event.clientX; + pan.currentY = event.clientY; + if (pan.frameId === null) { + pan.frameId = window.requestAnimationFrame(applyBoardPan); + } + }, [applyBoardPan]); + + const finishBoardPan = useCallback((event: ReactPointerEvent) => { + const pan = boardPanRef.current; + const container = boardScrollRef.current; + if (!pan || pan.pointerId !== event.pointerId) return; + + event.preventDefault(); + event.stopPropagation(); + pan.currentX = event.clientX; + pan.currentY = event.clientY; + if (pan.frameId !== null) { + window.cancelAnimationFrame(pan.frameId); + pan.frameId = null; + } + applyBoardPan(); + boardPanRef.current = null; + container?.classList.remove("cursor-grabbing", "select-none"); + if (container?.hasPointerCapture(event.pointerId)) { + container.releasePointerCapture(event.pointerId); + } + }, [applyBoardPan]); + + useEffect(() => { + const container = boardScrollRef.current; + if (!container || loading) return undefined; + + let scrollFrameId: number | null = null; + + const updateViewportHeight = () => { + const nextHeight = container.clientHeight; + setViewportHeight((currentHeight) => ( + currentHeight === nextHeight ? currentHeight : nextHeight + )); + }; + + const updateContentBottomState = () => { + scrollFrameId = null; + const updates = Array.from( + container.querySelectorAll(".tasks-board-column-group"), + (group) => { + const content = group.querySelector(".tasks-board-column-content"); + const stickyHeader = group.querySelector(".tasks-board-group-header-sticky"); + const reached = content && stickyHeader + ? content.getBoundingClientRect().bottom <= stickyHeader.getBoundingClientRect().bottom + 1 + : false; + return { group, value: reached ? "true" : "false" }; + }, + ); + + updates.forEach(({ group, value }) => { + if (group.dataset.contentBottomReached !== value) { + group.dataset.contentBottomReached = value; + } + }); + }; + + const scheduleContentBottomUpdate = () => { + if (scrollFrameId === null) { + scrollFrameId = window.requestAnimationFrame(updateContentBottomState); + } + }; + + const handleResize = () => { + updateViewportHeight(); + scheduleContentBottomUpdate(); + }; + + updateViewportHeight(); + updateContentBottomState(); + container.addEventListener("scroll", scheduleContentBottomUpdate, { passive: true }); + + const observer = new ResizeObserver(handleResize); + observer.observe(container); + container.querySelectorAll(".tasks-board-column-content").forEach((content) => { + observer.observe(content); + }); + + return () => { + container.removeEventListener("scroll", scheduleContentBottomUpdate); + observer.disconnect(); + if (scrollFrameId !== null) { + window.cancelAnimationFrame(scrollFrameId); + } + }; + }, [board, desktopWideMode, loading]); + + useEffect(() => () => { + const pan = boardPanRef.current; + if (pan?.frameId !== null && pan?.frameId !== undefined) { + window.cancelAnimationFrame(pan.frameId); + } + boardScrollRef.current?.classList.remove("cursor-grabbing", "select-none"); + boardPanRef.current = null; + }, []); + + return { + boardScrollRef, + finishBoardPan, + moveBoardPan, + startBoardPan, + viewportHeight, + }; +}