From 1da1fd1e38a03b0aa1482a498151cf70295317d5 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 24 Mar 2026 22:35:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=BF=E7=94=A8Virtua=E6=9B=BF=E6=8D=A2@tans?= =?UTF-8?q?tack/react-virtual=E6=9D=A5=E5=AE=9E=E7=8E=B0=E8=99=9A=E6=8B=9F?= =?UTF-8?q?=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +- pnpm-lock.yaml | 23 +--- .../sidebar-conversation-list.tsx | 55 ++------ src/components/message/message-list-view.tsx | 2 - .../message/virtualized-message-thread.tsx | 117 ++++++++---------- 5 files changed, 70 insertions(+), 131 deletions(-) diff --git a/package.json b/package.json index 6ecb961..5004bff 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "@streamdown/code": "^1.0.2", "@streamdown/math": "^1.0.2", "@streamdown/mermaid": "^1.0.2", - "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", @@ -54,7 +53,8 @@ "streamdown": "^2.2.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", - "use-stick-to-bottom": "^1.1.3" + "use-stick-to-bottom": "^1.1.3", + "virtua": "^0.48.8" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41da730..3314cfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,6 @@ importers: '@streamdown/mermaid': specifier: ^1.0.2 version: 1.0.2(react@19.2.4) - '@tanstack/react-virtual': - specifier: ^3.13.23 - version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tauri-apps/api': specifier: ^2 version: 2.10.1 @@ -143,6 +140,9 @@ importers: use-stick-to-bottom: specifier: ^1.1.3 version: 1.1.3(react@19.2.4) + virtua: + specifier: ^0.48.8 + version: 0.48.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) devDependencies: '@tailwindcss/postcss': specifier: ^4.1.18 @@ -2474,15 +2474,6 @@ packages: '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} - '@tanstack/react-virtual@3.13.23': - resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - '@tanstack/virtual-core@3.13.23': - resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} - '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} @@ -9098,14 +9089,6 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 - '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@tanstack/virtual-core': 3.13.23 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@tanstack/virtual-core@3.13.23': {} - '@tauri-apps/api@2.10.1': {} '@tauri-apps/cli-darwin-arm64@2.10.0': diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 3dd43ee..055c787 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -12,7 +12,7 @@ import { } from "react" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { useVirtualizer } from "@tanstack/react-virtual" +import { Virtualizer, type VirtualizerHandle } from "virtua" import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react" import { useFolderContext } from "@/contexts/folder-context" import { useTabContext } from "@/contexts/tab-context" @@ -71,7 +71,6 @@ type FlatItem = | { type: "header"; status: ConversationStatus; count: number } | { type: "conversation"; conversation: DbConversationSummary } -const HEADER_HEIGHT = 32 const CARD_HEIGHT = 62 const GroupHeader = memo(function GroupHeader({ @@ -209,8 +208,7 @@ export function SidebarConversationList({ const scrollToActiveRef = useRef<() => void>(() => {}) const pendingScrollRef = useRef(false) - const virtualizerRef = - useRef>>(null) + const virtualizerRef = useRef(null) useImperativeHandle(ref, () => ({ scrollToActive() { @@ -272,22 +270,6 @@ export function SidebarConversationList({ ) const reviewConversationCount = reviewConversations.length - const virtualizer = useVirtualizer({ - count: flatItems.length, - getScrollElement: () => scrollContainerRef.current, - estimateSize: (index) => - flatItems[index].type === "header" ? HEADER_HEIGHT : CARD_HEIGHT, - getItemKey: (index) => { - const item = flatItems[index] - return item.type === "header" - ? `header-${item.status}` - : `conv-${item.conversation.id}` - }, - overscan: 5, - }) - - virtualizerRef.current = virtualizer - useEffect(() => { scrollToActiveRef.current = () => { if (!selectedConversation) return @@ -312,7 +294,7 @@ export function SidebarConversationList({ if (index >= 0) { virtualizerRef.current?.scrollToIndex(index, { align: "center", - behavior: "smooth", + smooth: true, }) } } @@ -494,33 +476,20 @@ export function SidebarConversationList({ ref={scrollContainerRef} className={cn( "flex-1 min-h-0 overflow-y-auto px-1.5", + "[overflow-anchor:none]", "[&::-webkit-scrollbar]:w-1.5", "[&::-webkit-scrollbar-thumb]:rounded-full", "[&::-webkit-scrollbar-thumb]:bg-border" )} > -
- {virtualizer.getVirtualItems().map((virtualRow) => { - const item = flatItems[virtualRow.index] + + {flatItems.map((item) => { + const key = + item.type === "header" + ? `header-${item.status}` + : `conv-${item.conversation.id}` return ( -
+
{item.type === "header" ? ( item.status === "pending_review" ? ( ) })} -
+
diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 72375f9..2785917 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -376,8 +376,6 @@ export function MessageListView({ getItemKey={(item) => item.key} renderItem={renderThreadItem} emptyState={emptyState} - estimateSize={100} - overscan={10} /> diff --git a/src/components/message/virtualized-message-thread.tsx b/src/components/message/virtualized-message-thread.tsx index 676fd15..5f67fd4 100644 --- a/src/components/message/virtualized-message-thread.tsx +++ b/src/components/message/virtualized-message-thread.tsx @@ -1,8 +1,8 @@ "use client" -import { useCallback, useEffect } from "react" -import type { ReactNode } from "react" -import { useVirtualizer } from "@tanstack/react-virtual" +import { useMemo } from "react" +import type { CSSProperties, ReactNode, RefObject } from "react" +import { Virtualizer } from "virtua" import { useStickToBottomContext } from "use-stick-to-bottom" import { MessageThreadContent, @@ -11,15 +11,29 @@ import { import { cn } from "@/lib/utils" interface VirtualizedMessageThreadProps { + /** Data to virtualise — each entry becomes one virtual row. */ items: T[] + /** Stable key for a given item (used as React key). */ getItemKey: (item: T, index: number) => string + /** Render the content of one row. */ renderItem: (item: T, index: number) => ReactNode + /** Shown when `items` is empty. */ emptyState?: ReactNode - estimateSize?: number - overscan?: number + /** + * Hint for the initial height (px) of an unmeasured item. + * Virtua auto-measures every item once mounted, so this only + * affects the very first paint — omit it if you don't care. + */ + itemSize?: number + /** Vertical gap between items in px. @default 16 */ gap?: number + /** Vertical padding before the first / after the last item. @default 16 */ + padding?: number + /** Extra className on every item's inner wrapper (the `max-w-3xl` div). */ className?: string + /** Extra className on the MessageThreadContent shell. */ contentClassName?: string + /** Extra props forwarded to MessageThreadContent. */ contentProps?: Omit } @@ -28,83 +42,58 @@ export function VirtualizedMessageThread({ getItemKey, renderItem, emptyState, - estimateSize = 160, - overscan = 8, + itemSize, gap = 16, + padding = 16, className, contentClassName, contentProps, }: VirtualizedMessageThreadProps) { const { scrollRef } = useStickToBottomContext() - // eslint-disable-next-line react-hooks/incompatible-library - const virtualizer = useVirtualizer({ - count: items.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => estimateSize, - overscan, - useAnimationFrameWithResizeObserver: true, - isScrollingResetDelay: 100, - paddingStart: 16, - paddingEnd: 16, - gap, - getItemKey: (index) => { - const item = items[index] - return item ? getItemKey(item, index) : index - }, - }) + // Pre-compute the three possible padding styles so every render reuses + // the same object references (avoids allocating per-item on each frame). + const styles = useMemo(() => { + const halfGap = gap / 2 + return { + only: { paddingTop: padding, paddingBottom: padding } as CSSProperties, + first: { paddingTop: padding, paddingBottom: halfGap } as CSSProperties, + middle: { paddingTop: halfGap, paddingBottom: halfGap } as CSSProperties, + last: { paddingTop: halfGap, paddingBottom: padding } as CSSProperties, + } + }, [gap, padding]) - // Adjust scroll position when items above the viewport are measured - // differently from their estimates — prevents blank gaps when scrolling up - useEffect(() => { - virtualizer.shouldAdjustScrollPositionOnItemSizeChange = ( - item, - _delta, - instance - ) => item.start < (instance.scrollOffset ?? 0) - }, [virtualizer]) - - const renderVirtualRow = useCallback( - (virtualItem: ReturnType[number]) => { - const item = items[virtualItem.index] - if (!item) return null - - return ( -
-
- {renderItem(item, virtualItem.index)} -
-
- ) - }, - [className, items, renderItem, virtualizer] - ) + const itemStyle = (index: number, total: number) => { + if (total === 1) return styles.only + if (index === 0) return styles.first + if (index === total - 1) return styles.last + return styles.middle + } return ( {items.length === 0 ? ( (emptyState ?? null) ) : ( -
} + itemSize={itemSize} > - {virtualizer.getVirtualItems().map(renderVirtualRow)} -
+ {items.map((item, index) => ( +
+
+ {renderItem(item, index)} +
+
+ ))} +
)} )