perf(desktop): virtualize unbounded lists and warm the emoji index (#1089)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-17 19:01:09 +00:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 3bf2ac0047
commit a4fbebb397
17 changed files with 686 additions and 201 deletions
+1
View File
@@ -45,6 +45,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.168.10",
"@tanstack/react-virtual": "^3.14.2",
"@tauri-apps/api": "~2.11",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "~2.5",
+1
View File
@@ -48,6 +48,7 @@ export default defineConfig({
"**/thread-unread-screenshots.spec.ts",
"**/animated-avatar-screenshots.spec.ts",
"**/reminders-screenshots.spec.ts",
"**/virtualization-screenshots.spec.ts",
],
use: {
...devices["Desktop Chrome"],
@@ -42,7 +42,7 @@ export function AgentSessionTranscriptList({
role="log"
>
{items.map((item) => (
<div className="mt-4 first:mt-0" key={item.id}>
<div className="mt-4 first:mt-0 content-visibility-auto" key={item.id}>
<TranscriptItemView
agentName={agentName}
item={item}
@@ -36,6 +36,9 @@ export function ChannelCanvas({
const [draft, setDraft] = React.useState("");
const canvasContent = canvasQuery.data?.content ?? null;
// Defer the single large Markdown parse so opening the canvas commits the
// surrounding chrome immediately and the heavy render reconciles after.
const deferredCanvasContent = React.useDeferredValue(canvasContent);
function handleStartEditing() {
setDraft(canvasContent ?? "");
@@ -121,7 +124,10 @@ export function ChannelCanvas({
className="rounded-2xl border border-border/70 bg-muted/20 px-4 py-3"
data-testid="channel-canvas-content"
>
<Markdown channelNames={channelNames} content={canvasContent} />
<Markdown
channelNames={channelNames}
content={deferredCanvasContent ?? ""}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
@@ -432,46 +432,47 @@ export function MembersSidebar({
function renderMemberCard(member: ChannelMember, memberIsBot: boolean) {
return (
<MembersSidebarMemberCard
canChangeRole={canManageMembers && member.pubkey !== currentPubkey}
canRemoveMember={canRemoveMember(member)}
isActionPending={isActionPending || changeRoleMutation.isPending}
isArchived={isArchived}
key={member.pubkey}
managedAgent={
memberIsBot
? managedAgentByPubkey.get(normalizePubkey(member.pubkey))
: undefined
}
member={member}
memberIsBot={memberIsBot}
memberAvatarLabel={member.displayName ?? formatPubkey(member.pubkey)}
memberLabel={formatMemberName(member, currentPubkey)}
onChangeRole={(m, role) => {
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
}}
onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined}
onManagedAgentAction={(agent) => {
void handleAgentLifecycleAction(agent);
}}
onOpenProfile={handleOpenProfile}
onRemoveMember={handleRemoveMember}
onViewActivity={
onViewActivity
? (pubkey: string) => {
onOpenChange(false);
onViewActivity(pubkey);
}
: undefined
}
presenceStatus={
memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null
}
profileAvatarUrl={
memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()]
?.avatarUrl ?? null
}
/>
<div className="content-visibility-auto" key={member.pubkey}>
<MembersSidebarMemberCard
canChangeRole={canManageMembers && member.pubkey !== currentPubkey}
canRemoveMember={canRemoveMember(member)}
isActionPending={isActionPending || changeRoleMutation.isPending}
isArchived={isArchived}
managedAgent={
memberIsBot
? managedAgentByPubkey.get(normalizePubkey(member.pubkey))
: undefined
}
member={member}
memberIsBot={memberIsBot}
memberAvatarLabel={member.displayName ?? formatPubkey(member.pubkey)}
memberLabel={formatMemberName(member, currentPubkey)}
onChangeRole={(m, role) => {
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
}}
onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined}
onManagedAgentAction={(agent) => {
void handleAgentLifecycleAction(agent);
}}
onOpenProfile={handleOpenProfile}
onRemoveMember={handleRemoveMember}
onViewActivity={
onViewActivity
? (pubkey: string) => {
onOpenChange(false);
onViewActivity(pubkey);
}
: undefined
}
presenceStatus={
memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null
}
profileAvatarUrl={
memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()]
?.avatarUrl ?? null
}
/>
</div>
);
}
@@ -1,10 +1,33 @@
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { init } from "emoji-mart";
import * as React from "react";
import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
// emoji-mart builds its searchable index synchronously inside `init`, which
// `<Picker>` calls on mount — so the first reaction popover open paid the full
// ~1.8k-emoji index build and froze the cursor. Warm `init({ data })` once at
// idle so the index is prebuilt; `init` is a no-op after the first call (its
// `Data` singleton guards the rebuild), so the Picker's mount-time `init` skips
// the heavy work. Search still reads the prebuilt index — no first-keystroke
// hitch. Module-level so it fires regardless of when a picker first mounts.
let warmStarted = false;
function warmEmojiIndex() {
if (warmStarted) {
return;
}
warmStarted = true;
const warm = () => void init({ data });
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
window.requestIdleCallback(warm, { timeout: 1_500 });
} else {
globalThis.setTimeout(warm, 250);
}
}
warmEmojiIndex();
/**
* The one emoji picker for the whole app. Every place that lets a user choose
* an emoji — composing a message, reacting to a regular or system message,
@@ -75,7 +75,10 @@ function ReplyRow({
const replyMentionNames = resolveMentionNames(reply.tags, profiles);
return (
<div className="group px-4 py-3" data-forum-event-id={reply.eventId}>
<div
className="group content-visibility-auto px-4 py-3"
data-forum-event-id={reply.eventId}
>
<div className="flex items-center gap-2">
<UserProfilePopover pubkey={reply.pubkey}>
<button
+29 -20
View File
@@ -7,6 +7,7 @@ import type { Channel } from "@/shared/api/types";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { Skeleton } from "@/shared/ui/skeleton";
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
import {
useCreateForumPostMutation,
@@ -47,6 +48,7 @@ export function ForumView({
targetReplyId,
}: ForumViewProps) {
const [isComposerOpen, setIsComposerOpen] = React.useState(false);
const postsScrollRef = React.useRef<HTMLDivElement>(null);
const profileQuery = useProfileQuery();
const postsQuery = useForumPostsQuery(channel);
@@ -185,6 +187,7 @@ export function ForumView({
<div
className="flex-1 overflow-y-auto"
data-scroll-restoration-id={`forum-list:${channel.id}`}
ref={postsScrollRef}
>
{postsQuery.isLoading ? (
<div className="space-y-3 p-4">
@@ -205,26 +208,32 @@ export function ForumView({
</div>
</div>
) : (
<div className="space-y-3 p-4">
{posts.map((post) => (
<ForumPostCard
canDelete={canDelete(post.pubkey, effectiveCurrentPubkey)}
currentPubkey={effectiveCurrentPubkey}
isActive={selectedPostId === post.eventId}
isDeleting={
deletePostMutation.isPending &&
deletePostMutation.variables?.eventId === post.eventId
}
key={post.eventId}
onClick={() => onSelectPost(post.eventId)}
onDelete={(eventId) => {
deletePostMutation.mutate({ eventId });
}}
post={post}
profiles={profiles}
/>
))}
</div>
<VirtualizedList
estimateSize={120}
getItemKey={(post) => post.eventId}
innerClassName="p-4"
items={posts}
renderItem={(post) => (
<div className="pb-3">
<ForumPostCard
canDelete={canDelete(post.pubkey, effectiveCurrentPubkey)}
currentPubkey={effectiveCurrentPubkey}
isActive={selectedPostId === post.eventId}
isDeleting={
deletePostMutation.isPending &&
deletePostMutation.variables?.eventId === post.eventId
}
onClick={() => onSelectPost(post.eventId)}
onDelete={(eventId) => {
deletePostMutation.mutate({ eventId });
}}
post={post}
profiles={profiles}
/>
</div>
)}
scrollRef={postsScrollRef}
/>
)}
</div>
</div>
+95 -86
View File
@@ -1,4 +1,5 @@
import { ChevronDown, Inbox } from "lucide-react";
import * as React from "react";
import {
formatInboxTypeLabel,
@@ -18,6 +19,7 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [
{ value: "all", label: "All" },
@@ -47,6 +49,91 @@ export function InboxListPane({
showRightDivider = false,
}: InboxListPaneProps) {
const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter);
const scrollRef = React.useRef<HTMLDivElement>(null);
const renderItem = (item: InboxItem) => {
const isSelected = item.id === selectedId;
const isDone = doneSet.has(item.id);
const typeLabel = formatInboxTypeLabel(item);
return (
<button
className={cn(
"flex w-full items-start gap-2.5 border-l px-5 py-2 text-left transition-colors",
isSelected
? "border-l-transparent bg-muted/30"
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
)}
data-testid={`home-inbox-item-${item.id}`}
onClick={() => onSelect(item.id)}
type="button"
>
<div className="relative">
<UserAvatar
avatarUrl={item.avatarUrl}
className="h-8 w-8"
displayName={item.senderLabel}
size="md"
/>
{!isDone ? (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border-2 border-background bg-primary" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold text-foreground">
{item.senderLabel}
</p>
{item.isActionRequired ? (
<span className="inline-flex shrink-0 items-center text-2xs font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
Needs action
</span>
) : null}
</div>
</div>
<span
className={cn(
"shrink-0 text-xs text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
{item.timestampLabel}
</span>
</div>
<div
className={cn(
"mt-0.5 line-clamp-2 text-sm leading-5 **:inline [&_a]:font-medium [&_a]:text-current [&_br]:hidden [&_p]:inline",
isDone
? "font-normal text-muted-foreground"
: "font-semibold text-foreground",
)}
>
<Markdown
className="inline max-w-full text-inherit"
content={item.preview}
interactive={false}
mentionNames={item.mentionNames}
/>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
className={cn(
"text-2xs text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
{typeLabel}
</span>
</div>
</div>
</button>
);
};
return (
<section
@@ -101,6 +188,7 @@ export function InboxListPane({
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
data-testid="home-inbox-list"
ref={scrollRef}
>
{items.length === 0 ? (
<div className="flex h-full min-h-64 items-center justify-center px-6 text-center">
@@ -114,92 +202,13 @@ export function InboxListPane({
</div>
</div>
) : (
<div>
{items.map((item) => {
const isSelected = item.id === selectedId;
const isDone = doneSet.has(item.id);
const typeLabel = formatInboxTypeLabel(item);
return (
<button
className={cn(
"flex w-full items-start gap-2.5 border-l px-5 py-2 text-left transition-colors",
isSelected
? "border-l-transparent bg-muted/30"
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
)}
data-testid={`home-inbox-item-${item.id}`}
key={item.id}
onClick={() => onSelect(item.id)}
type="button"
>
<div className="relative">
<UserAvatar
avatarUrl={item.avatarUrl}
className="h-8 w-8"
displayName={item.senderLabel}
size="md"
/>
{!isDone ? (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border-2 border-background bg-primary" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold text-foreground">
{item.senderLabel}
</p>
{item.isActionRequired ? (
<span className="inline-flex shrink-0 items-center text-2xs font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
Needs action
</span>
) : null}
</div>
</div>
<span
className={cn(
"shrink-0 text-xs text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
{item.timestampLabel}
</span>
</div>
<div
className={cn(
"mt-0.5 line-clamp-2 text-sm leading-5 **:inline [&_a]:font-medium [&_a]:text-current [&_br]:hidden [&_p]:inline",
isDone
? "font-normal text-muted-foreground"
: "font-semibold text-foreground",
)}
>
<Markdown
className="inline max-w-full text-inherit"
content={item.preview}
interactive={false}
mentionNames={item.mentionNames}
/>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
className={cn(
"text-2xs text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
{typeLabel}
</span>
</div>
</div>
</button>
);
})}
</div>
<VirtualizedList
estimateSize={76}
getItemKey={(item) => item.id}
items={items}
renderItem={renderItem}
scrollRef={scrollRef}
/>
)}
</div>
</section>
+49 -35
View File
@@ -29,6 +29,7 @@ import type { ChannelMember, UserProfileSummary } from "@/shared/api/types";
import { Input } from "@/shared/ui/input";
import { Skeleton } from "@/shared/ui/skeleton";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
export type PulseTab =
| "search"
@@ -73,6 +74,7 @@ function TimelineSkeleton() {
export function PulseView({ currentPubkey }: PulseViewProps) {
const [activeTab, setActiveTab] = React.useState<PulseTab>("everyone");
const [searchQuery, setSearchQuery] = React.useState("");
const scrollRef = React.useRef<HTMLDivElement>(null);
const contactListQuery = useContactListQuery(currentPubkey);
const contacts = contactListQuery.data?.contacts ?? [];
const contactPubkeys = React.useMemo(
@@ -268,43 +270,57 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
return agentNoteGroups.length === 0 ? (
<EmptyState message={emptyMessages.agents} />
) : (
agentNoteGroups.map((group) => (
<AgentActivityCard
agentStatus={agentStatusMap[group.pubkey]}
group={group}
key={`${group.pubkey}-${group.latestAt}`}
profile={profiles[group.pubkey.toLowerCase()] ?? null}
/>
))
<VirtualizedList
estimateSize={160}
getItemKey={(group) => `${group.pubkey}-${group.latestAt}`}
items={agentNoteGroups}
renderItem={(group) => (
<div className="pb-4">
<AgentActivityCard
agentStatus={agentStatusMap[group.pubkey]}
group={group}
profile={profiles[group.pubkey.toLowerCase()] ?? null}
/>
</div>
)}
scrollRef={scrollRef}
/>
);
}
return visibleNotes.length === 0 ? (
<EmptyState message={emptyMessages[activeTab]} />
) : (
visibleNotes.map((note) => (
<NoteCard
actions={{
reply: noteActions.reply,
share: noteActions.share,
startDm: noteActions.startDm,
toggleUpvote: noteActions.toggleUpvote,
}}
composerProfiles={mentionProfiles}
currentUserDisplayName={currentDisplayName}
currentUserProfile={currentProfile}
isAgent={agentPubkeySet.has(note.pubkey)}
isOwnNote={note.pubkey === currentPubkey}
isReplySending={noteActions.isReplySending}
isUpvotePending={noteActions.isUpvotePending(note.id)}
isUpvoted={noteActions.isUpvoted(note.id)}
reactionCount={noteActions.reactionCount(note.id)}
key={note.id}
members={pulseMentionMembers}
note={note}
profile={profiles[note.pubkey.toLowerCase()] ?? null}
/>
))
<VirtualizedList
estimateSize={140}
getItemKey={(note) => note.id}
items={visibleNotes}
renderItem={(note) => (
<div className="pb-4">
<NoteCard
actions={{
reply: noteActions.reply,
share: noteActions.share,
startDm: noteActions.startDm,
toggleUpvote: noteActions.toggleUpvote,
}}
composerProfiles={mentionProfiles}
currentUserDisplayName={currentDisplayName}
currentUserProfile={currentProfile}
isAgent={agentPubkeySet.has(note.pubkey)}
isOwnNote={note.pubkey === currentPubkey}
isReplySending={noteActions.isReplySending}
isUpvotePending={noteActions.isUpvotePending(note.id)}
isUpvoted={noteActions.isUpvoted(note.id)}
reactionCount={noteActions.reactionCount(note.id)}
members={pulseMentionMembers}
note={note}
profile={profiles[note.pubkey.toLowerCase()] ?? null}
/>
</div>
)}
scrollRef={scrollRef}
/>
);
}
@@ -318,7 +334,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
relayAgents={relayAgents}
/>
<div className="mt-0 min-h-0 flex-1 overflow-y-auto">
<div className="mt-0 min-h-0 flex-1 overflow-y-auto" ref={scrollRef}>
<div
aria-labelledby={pulseTabId(activeTab)}
className={`mx-auto flex w-full max-w-2xl flex-col px-4 pb-10 sm:px-6 ${
@@ -399,9 +415,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
</div>
) : null}
{activeTab !== "search" ? (
<div className="space-y-4">{renderTimeline()}</div>
) : null}
{activeTab !== "search" ? <div>{renderTimeline()}</div> : null}
</div>
</div>
</div>
@@ -36,6 +36,7 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Input } from "@/shared/ui/input";
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
type AssignableRelayRole = Exclude<RelayMemberRole, "owner">;
@@ -486,17 +487,22 @@ export function RelayMembersSettingsCard({
No members match your search.
</p>
) : (
<div className="max-h-[28rem] space-y-2 overflow-y-auto pr-1">
{filteredMembers.map((member) => (
<RelayMemberRow
currentPubkey={currentPubkey}
currentRole={currentRole}
key={member.pubkey}
member={member}
profile={profiles?.[normalizePubkey(member.pubkey)]}
/>
))}
</div>
<VirtualizedList
className="max-h-[28rem] pr-1"
estimateSize={56}
getItemKey={(member) => member.pubkey}
items={filteredMembers}
renderItem={(member) => (
<div className="pb-2">
<RelayMemberRow
currentPubkey={currentPubkey}
currentRole={currentRole}
member={member}
profile={profiles?.[normalizePubkey(member.pubkey)]}
/>
</div>
)}
/>
)}
</div>
</div>
@@ -363,7 +363,7 @@ export function ChannelGroupSection({
{items.map((channel) => (
<ContextMenu key={channel.id}>
<ContextMenuTrigger asChild>
<SidebarMenuItem>
<SidebarMenuItem className="content-visibility-auto-row">
{draggable ? (
<DraggableChannelRow channelId={channel.id}>
<ChannelMenuButton
+17
View File
@@ -1768,6 +1768,23 @@
}
@layer utilities {
/*
* Skips rendering (layout, paint) for offscreen rows while keeping them in
* the DOM — unlike windowing, in-DOM state (open `<details>`, drag-and-drop,
* deep-link `querySelector`) survives. The intrinsic size is a height
* placeholder so the scrollbar stays stable before a row is first rendered.
*/
.content-visibility-auto {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
}
/* Compact variant for short single-line rows (sidebar channels, etc). */
.content-visibility-auto-row {
content-visibility: auto;
contain-intrinsic-size: auto 2rem;
}
.buzz-huddle-tooltip {
--primary: var(
--huddle-tooltip-surface,
+144
View File
@@ -0,0 +1,144 @@
import { type Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
export type ListVirtualizer = Virtualizer<HTMLElement, Element>;
/**
* A headless virtualized list primitive using @tanstack/react-virtual.
*
* Migration contract:
* - Rows must tolerate unmount/remount (no DOM-resident state that can't be
* reconstructed from props/data). Surfaces with in-DOM row state (open
* `<details>`, drag-and-drop) should use `content-visibility` instead.
* - Rows may have variable height the library's `measureElement` handles
* dynamic sizing automatically.
*
* Supports:
* (a) Optional non-virtualized sticky-header slot rendered above the virtual
* rows inside the scroll container (for PulseView's sticky composer, etc).
* (b) Optional externally-owned scroll container pass `scrollRef` when the
* caller already owns the scrolling element (a surface that shares its
* scroll region with non-row siblings). When omitted, VirtualizedList
* renders its own `overflow-y-auto` container.
*/
type VirtualizedListProps<T> = {
/** The data items to virtualize. */
items: T[];
/** Stable key extractor for each item. */
getItemKey: (item: T, index: number) => string | number;
/** Render function for each row. Receives the item and its index. */
renderItem: (item: T, index: number) => React.ReactNode;
/** Estimated row height in px — used before measurement. */
estimateSize?: number;
/** Optional non-virtualized content rendered above the virtual rows (sticky headers, etc). */
stickyHeader?: React.ReactNode;
/**
* Externally-owned scroll container. When provided, no internal scroll
* container is rendered the caller's element scrolls and is measured.
*/
scrollRef?: React.RefObject<HTMLElement | null>;
/** Class name for the internal scroll container (ignored when scrollRef is provided). */
className?: string;
/** Class name for the inner spacer div that holds the virtual rows. */
innerClassName?: string;
/** Overscan — number of items to render outside the visible area. */
overscan?: number;
/** Receives the virtualizer instance (for `scrollToIndex`, etc). */
onVirtualizer?: (virtualizer: ListVirtualizer) => void;
};
export function VirtualizedList<T>({
items,
getItemKey,
renderItem,
estimateSize = 80,
stickyHeader,
scrollRef,
className,
innerClassName,
overscan = 5,
onVirtualizer,
}: VirtualizedListProps<T>) {
const internalScrollRef = React.useRef<HTMLDivElement>(null);
const spacerRef = React.useRef<HTMLDivElement>(null);
const ownsScroll = scrollRef === undefined;
const resolvedScrollRef = scrollRef ?? internalScrollRef;
// Read the element lazily inside the callback so the virtualizer picks it up
// once the ref attaches — capturing `ref.current` at render time would freeze
// it at the first-render `null`.
const getScrollElement = React.useCallback(
() => resolvedScrollRef.current,
[resolvedScrollRef],
);
// When a sticky header (or any caller content) sits above the rows in the
// same scroll container, the row spacer no longer starts at scrollTop 0.
// Feed that offset to the virtualizer as `scrollMargin` so the visible-range
// math stays aligned; without it the wrong rows render near the top.
const [scrollMargin, setScrollMargin] = React.useState(0);
React.useLayoutEffect(() => {
const scrollEl = resolvedScrollRef.current;
const spacer = spacerRef.current;
if (!scrollEl || !spacer) {
return;
}
const offset =
spacer.getBoundingClientRect().top -
scrollEl.getBoundingClientRect().top +
scrollEl.scrollTop;
setScrollMargin((prev) => (prev === offset ? prev : offset));
});
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement,
estimateSize: () => estimateSize,
getItemKey: (index) => getItemKey(items[index], index),
overscan,
scrollMargin,
});
React.useEffect(() => {
onVirtualizer?.(virtualizer);
}, [onVirtualizer, virtualizer]);
const content = (
<>
{stickyHeader}
<div
className={cn("relative w-full", innerClassName)}
ref={spacerRef}
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
data-index={virtualRow.index}
key={virtualRow.key}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
}}
>
{renderItem(items[virtualRow.index], virtualRow.index)}
</div>
))}
</div>
</>
);
if (ownsScroll) {
return (
<div className={cn("overflow-y-auto", className)} ref={internalScrollRef}>
{content}
</div>
);
}
return content;
}
+36 -1
View File
@@ -2211,6 +2211,31 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
content: "Looks good to me. We should ship it.",
sig: "mocksig".repeat(20).slice(0, 128),
},
// Filler replies so the thread overflows the panel viewport — the
// deep-link target (mock-forum-release-deeplink) sits below the fold
// at open, proving scrollIntoView lands an offscreen content-
// visibility row. Named IDs above are untouched.
...Array.from({ length: 24 }, (_, index) => ({
id:
index === 23
? "mock-forum-release-deeplink"
: `mock-forum-release-filler-${index}`,
pubkey: ALICE_PUBKEY,
created_at: Math.floor(Date.now() / 1000) - (79 - index) * 60,
kind: 45003,
tags: buildReplyMessageTags(
channelId,
ALICE_PUBKEY,
"mock-forum-release-thread",
"mock-forum-release-thread",
undefined,
),
content:
index === 23
? "Deep-link target: confirmed the rollout plan end to end."
: `Follow-up note #${index + 1} on the release checklist.`,
sig: "mocksig".repeat(20).slice(0, 128),
})),
]
: channelId === "94a444a4-c0a3-5966-ab05-530c6ddc2301"
? [
@@ -2522,7 +2547,9 @@ function getMockUserNotes(pubkey: string): RawUserNote[] {
const now = Math.floor(Date.now() / 1000);
if (pubkey === DEFAULT_MOCK_IDENTITY.pubkey) {
return [
// Two named notes plus generated filler so the Pulse feed overflows the
// viewport — required to exercise windowed scroll + sticky-composer offset.
const named: RawUserNote[] = [
{
id: "mock-note-launch",
pubkey,
@@ -2538,6 +2565,14 @@ function getMockUserNotes(pubkey: string): RawUserNote[] {
tags: [],
},
];
const filler: RawUserNote[] = Array.from({ length: 28 }, (_, index) => ({
id: `mock-note-filler-${index}`,
pubkey,
created_at: now - (4 + index) * 60 * 60,
content: `Pulse update #${index + 1}: tracking virtualization rollout across desktop surfaces.`,
tags: [],
}));
return [...named, ...filler];
}
if (pubkey === ALICE_PUBKEY) {
@@ -0,0 +1,199 @@
import { expect, test } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// Screenshot capture for the desktop list-virtualization pass (PR #1089). Each
// shot is gated by an assertion so a geometry regression fails the run rather
// than silently producing a misleading image. The shots are the empirical gate
// that closes the correctness residual flagged in review: the absolute-position
// / scrollMargin geometry under live dynamic measurement, and the
// content-visibility "rows stay committed" claim on the dnd-coupled surface.
// Artifacts land in test-results/virtualization/.
const SHOTS = "test-results/virtualization";
const WATERCOOLER_CHANNEL_ID = "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11";
const FORUM_THREAD_ID = "mock-forum-release-thread";
const FORUM_DEEPLINK_REPLY_ID = "mock-forum-release-deeplink";
// Mock-mode current-user pubkey (DEFAULT_MOCK_IDENTITY). Custom channel
// sections persist under buzz-channel-sections.v1:<pubkey>, so shot 6 seeds two
// sections for this key before the app boots.
const MOCK_PUBKEY = "deadbeef".repeat(8);
const SECTION_TOP = { id: "sec-top", name: "Priority", order: 0 };
const SECTION_BOTTOM = { id: "sec-bottom", name: "Archive", order: 1 };
async function seedChannelSections(page: Page) {
await page.addInitScript(
({ pubkey, sections }) => {
window.localStorage.setItem(
`buzz-channel-sections.v1:${pubkey}`,
JSON.stringify({ version: 1, sections, assignments: {} }),
);
},
{ pubkey: MOCK_PUBKEY, sections: [SECTION_TOP, SECTION_BOTTOM] },
);
}
// dnd-kit's PointerSensor activates only after the pointer travels past its
// 6px distance constraint, so a single move never starts a drag. This walks the
// pointer down, past the activation threshold, onto the target, then releases —
// the sequence dnd-kit needs to fire onDragEnd and commit the reorder.
async function dragOver(page: Page, source: Locator, target: Locator) {
const from = await source.boundingBox();
const to = await target.boundingBox();
if (!from || !to) throw new Error("drag handles not laid out");
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 + 10);
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, {
steps: 10,
});
await page.mouse.up();
}
test.describe("list virtualization screenshots", () => {
test("01 — Pulse windowed feed with sticky composer pinned mid-scroll", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("open-pulse-view").click();
// The seeded feed overflows the viewport (30 notes), so the windowed list
// renders a subset and the composer stays pinned. Wait for virtual rows.
const rows = page.locator("[data-index]");
await expect(rows.first()).toBeVisible();
const composer = page.locator(".pulse-composer");
await expect(composer).toBeVisible();
// Scroll the feed mid-list, then prove the sticky composer is still pinned
// at the top of its scroll container — this exercises the
// translateY(start - scrollMargin) offset under a non-zero scrollTop.
const scroller = composer.locator(
"xpath=ancestor::*[contains(@class,'overflow-y-auto')][1]",
);
await scroller.evaluate((el) => {
el.scrollTop = 600;
});
await expect
.poll(async () =>
composer.evaluate(
(el, scrollEl) => {
const composerTop = el.getBoundingClientRect().top;
const scrollTop = (scrollEl as HTMLElement).getBoundingClientRect()
.top;
return Math.abs(composerTop - scrollTop);
},
await scroller.elementHandle(),
),
)
.toBeLessThan(80);
await page.screenshot({ path: `${SHOTS}/01-pulse-sticky-composer.png` });
});
test("02 — forum deep-link lands on an offscreen reply", async ({ page }) => {
await installMockBridge(page);
await page.goto(
`/#/channels/${WATERCOOLER_CHANNEL_ID}/posts/${FORUM_THREAD_ID}?replyId=${FORUM_DEEPLINK_REPLY_ID}`,
);
// The deep-link target is the last of 25 replies — offscreen at open. Under
// content-visibility the row stays queryable, so scrollIntoView lands it.
const target = page.locator(
`[data-forum-event-id="${FORUM_DEEPLINK_REPLY_ID}"]`,
);
await expect(target).toBeVisible();
await expect(target).toContainText("Deep-link target");
// Assert the row sits within the viewport vertically — proves the scroll
// actually moved to it rather than leaving it below the fold.
await expect
.poll(async () =>
target.evaluate((el) => {
const rect = el.getBoundingClientRect();
return rect.top >= 0 && rect.bottom <= window.innerHeight;
}),
)
.toBe(true);
await page.screenshot({ path: `${SHOTS}/02-forum-deeplink-offscreen.png` });
});
test("03 — members search shows both sticky titles under content-visibility", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
// "a" matches member `alice` (Members section) and non-member `charlie`
// (Not in this channel section) — both heterogeneous lists + both sticky
// titles must stay alive under content-visibility.
await page.getByTestId("channel-management-search-users").fill("a");
await expect(page.getByText("Members", { exact: true })).toBeVisible();
await expect(
page.getByText("Not in this channel", { exact: true }),
).toBeVisible();
// A member row and an add-search (non-member) row both rendered.
await expect(
page.getByTestId("members-sidebar-people").getByText("alice"),
).toBeVisible();
await expect(
page.locator('[data-testid^="channel-user-search-result-"]').first(),
).toBeVisible();
await page.screenshot({
path: `${SHOTS}/03-members-both-sticky-titles.png`,
});
});
test("06 — custom-section dnd reorder commits under content-visibility", async ({
page,
}) => {
await seedChannelSections(page);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// dnd-kit marks each section's wrapping row with role="button" +
// aria-roledescription="sortable" and spreads the drag listeners there, so
// the row itself is the handle. Scoping to that attribute reads the live
// section order and excludes the inner disclosure button and the (hidden)
// assign-to-section context-menu items that reuse the same names.
const headers = page.locator('[aria-roledescription="sortable"]');
const topHeader = headers.filter({ hasText: "Priority" });
const bottomHeader = headers.filter({ hasText: "Archive" });
await expect(topHeader).toBeVisible();
await expect(bottomHeader).toBeVisible();
await expect(headers).toHaveCount(2);
const sectionOrder = async () =>
headers.evaluateAll((rows) =>
rows.map((row) =>
row.textContent?.trim().startsWith("Priority")
? "Priority"
: "Archive",
),
);
expect(await sectionOrder()).toEqual(["Priority", "Archive"]);
await page.screenshot({ path: `${SHOTS}/06a-sections-before-reorder.png` });
// Drag "Priority" past "Archive" — onDragEnd commits arrayMove and persists
// the new order. The drop must land for the order to flip.
await dragOver(page, topHeader, bottomHeader);
// The drop landed: order flipped. A no-op drag would leave it unchanged.
await expect.poll(sectionOrder).toEqual(["Archive", "Priority"]);
// Both section rows stayed committed in the DOM across the reorder — the
// content-visibility invariant the divergence rests on (no unmount).
await expect(headers).toHaveCount(2);
await page.screenshot({ path: `${SHOTS}/06b-sections-after-reorder.png` });
});
});
+21 -4
View File
@@ -90,6 +90,9 @@ importers:
'@tanstack/react-router':
specifier: ^1.168.10
version: 1.170.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tanstack/react-virtual':
specifier: ^3.14.2
version: 3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tauri-apps/api':
specifier: ~2.11
version: 2.11.0
@@ -452,14 +455,12 @@ packages:
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.4.16':
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.4.16':
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
@@ -1549,6 +1550,12 @@ packages:
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/react-virtual@3.14.2':
resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
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/router-core@1.171.5':
resolution: {integrity: sha512-BfilbQqqWiQwJn68cD8wmk1ajEWIO3IlEA1zVuWslWbiVc23CDn+6ACO5tfPAcc96ED37hxela5ij3VBvAtusw==}
engines: {node: '>=20.19'}
@@ -1585,6 +1592,9 @@ packages:
'@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@tanstack/virtual-core@3.17.0':
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
'@tanstack/virtual-file-routes@1.162.0':
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
engines: {node: '>=20.19'}
@@ -1629,7 +1639,6 @@ packages:
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
@@ -3051,7 +3060,7 @@ packages:
engines: {node: '>= 0.4'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4205,6 +4214,12 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
use-sync-external-store: 1.6.0(react@19.2.7)
'@tanstack/react-virtual@3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@tanstack/virtual-core': 3.17.0
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@tanstack/router-core@1.171.5':
dependencies:
'@tanstack/history': 1.162.0
@@ -4262,6 +4277,8 @@ snapshots:
'@tanstack/store@0.9.3': {}
'@tanstack/virtual-core@3.17.0': {}
'@tanstack/virtual-file-routes@1.162.0': {}
'@tauri-apps/api@2.11.0': {}