From da0e3a417683eb7ec2c02a1cd7ff6052353a9989 Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:36:51 +0300 Subject: [PATCH] auto-animate virtualization fix --- src/pages/Settings/RemotesSection.tsx | 75 ++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/src/pages/Settings/RemotesSection.tsx b/src/pages/Settings/RemotesSection.tsx index 617be53..8ce8b65 100644 --- a/src/pages/Settings/RemotesSection.tsx +++ b/src/pages/Settings/RemotesSection.tsx @@ -1,3 +1,4 @@ +import { useAutoAnimate } from '@formkit/auto-animate/react' import { Button, Card, @@ -22,7 +23,15 @@ import { SettingsIcon, Trash2Icon, } from 'lucide-react' -import { type ReactNode, startTransition, useEffect, useMemo, useRef, useState } from 'react' +import { + type ReactNode, + startTransition, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { useSearchParams } from 'react-router-dom' import { onErrorDialog } from '../../../lib/errors' import { formatBytes } from '../../../lib/format' @@ -85,6 +94,10 @@ export default function RemotesSection() { const [searchParams] = useSearchParams() const licenseValid = usePersistedStore((state) => state.licenseValid) + const [editingDrawerOpen, setEditingDrawerOpen] = useState(false) + const [creatingDrawerOpen, setCreatingDrawerOpen] = useState(false) + const [autoMountDrawerOpen, setAutoMountDrawerOpen] = useState(false) + const remotesQuery = useQuery({ queryKey: ['remotes', 'list', 'all'], queryFn: async () => { @@ -95,6 +108,7 @@ export default function RemotesSection() { return remotes }, staleTime: 1000 * 60, // 1 minute + enabled: !editingDrawerOpen && !creatingDrawerOpen && !autoMountDrawerOpen, }) const remotes = useMemo(() => remotesQuery.data ?? [], [remotesQuery.data]) @@ -136,11 +150,54 @@ export default function RemotesSection() { overscan: 6, }) - const [pickedRemote, setPickedRemote] = useState(null) + // Entrance animation for the list's first appearance only. Inactive tab + // panels are display:none, so the scroll element measures 0 until the + // Remotes tab is shown; rows are held back until then, animate in as they + // are added, and auto-animate is switched off afterwards so scrolling + // (rows mounting/unmounting) and later edits stay instant. + const [animateListRef, setListAnimated] = useAutoAnimate() + const listEl = useRef(null) + // Stable identity: a fresh callback each render would re-attach the ref + // (null → el) every time, and auto-animate's ref sets state → render loop. + const listRef = useCallback( + (el: HTMLDivElement | null) => { + listEl.current = el + animateListRef(el) + }, + [animateListRef] + ) + const listVisible = (rowVirtualizer.scrollRect?.height ?? 0) > 0 + // auto-animate also FLIP-animates the list container itself on every + // mutation, from the position it last measured. Attached while the panel + // is hidden it would cache a 0×0 rect and, on the first mutation, slide the + // whole list in from the panel's corner. So the controller is attached + // only once the list is visible, and the rows are added one commit later — + // the mutation auto-animate needs to see for the rows' entrance. + const [rowsReady, setRowsReady] = useState(false) + useEffect(() => { + if (listVisible) setRowsReady(true) + }, [listVisible]) + const virtualItems = listVisible && rowsReady ? rowVirtualizer.getVirtualItems() : [] + const hasAnimatedIn = useRef(false) + useEffect(() => { + if (hasAnimatedIn.current || virtualItems.length === 0) return + hasAnimatedIn.current = true + let cancelled = false + // Disabling cancels in-flight animations, so wait for the entrance + // animations (created by auto-animate's mutation observer, a + // microtask after this commit) to finish before switching it off. + const id = setTimeout(async () => { + const entrances = listEl.current?.getAnimations({ subtree: true }) ?? [] + await Promise.allSettled(entrances.map((animation) => animation.finished)) + if (!cancelled) setListAnimated(false) + }, 0) + return () => { + cancelled = true + clearTimeout(id) + } + }, [virtualItems.length, setListAnimated]) - const [editingDrawerOpen, setEditingDrawerOpen] = useState(false) - const [creatingDrawerOpen, setCreatingDrawerOpen] = useState(false) - const [autoMountDrawerOpen, setAutoMountDrawerOpen] = useState(false) + const [pickedRemote, setPickedRemote] = useState(null) const deleteRemoteMutation = useMutation({ mutationFn: async (remote: string) => { @@ -293,21 +350,23 @@ export default function RemotesSection() { className="overflow-y-auto overscroll-none max-h-[calc(100dvh-14rem)] pb-10" >
- {rowVirtualizer.getVirtualItems().map((virtualRow) => { + {virtualItems.map((virtualRow) => { const row = rows[virtualRow.index] + // Offset via `top`, not translateY: the entrance + // animation drives `transform` and would override it. const style = { position: 'absolute', - top: 0, + top: `${virtualRow.start}px`, left: 0, width: '100%', height: `${virtualRow.size}px`, - transform: `translateY(${virtualRow.start}px)`, } as const if (row.type === 'header') {