mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
3bf2ac0047
commit
a4fbebb397
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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` });
|
||||
});
|
||||
});
|
||||
Generated
+21
-4
@@ -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': {}
|
||||
|
||||
Reference in New Issue
Block a user