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 15:01:09 -04:00
committed by GitHub
parent 3bf2ac0047
commit a4fbebb397
17 changed files with 686 additions and 201 deletions

View File

@@ -45,6 +45,7 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.168.10", "@tanstack/react-router": "^1.168.10",
"@tanstack/react-virtual": "^3.14.2",
"@tauri-apps/api": "~2.11", "@tauri-apps/api": "~2.11",
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "~2.5", "@tauri-apps/plugin-opener": "~2.5",

View File

@@ -48,6 +48,7 @@ export default defineConfig({
"**/thread-unread-screenshots.spec.ts", "**/thread-unread-screenshots.spec.ts",
"**/animated-avatar-screenshots.spec.ts", "**/animated-avatar-screenshots.spec.ts",
"**/reminders-screenshots.spec.ts", "**/reminders-screenshots.spec.ts",
"**/virtualization-screenshots.spec.ts",
], ],
use: { use: {
...devices["Desktop Chrome"], ...devices["Desktop Chrome"],

View File

@@ -42,7 +42,7 @@ export function AgentSessionTranscriptList({
role="log" role="log"
> >
{items.map((item) => ( {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 <TranscriptItemView
agentName={agentName} agentName={agentName}
item={item} item={item}

View File

@@ -36,6 +36,9 @@ export function ChannelCanvas({
const [draft, setDraft] = React.useState(""); const [draft, setDraft] = React.useState("");
const canvasContent = canvasQuery.data?.content ?? null; 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() { function handleStartEditing() {
setDraft(canvasContent ?? ""); setDraft(canvasContent ?? "");
@@ -121,7 +124,10 @@ export function ChannelCanvas({
className="rounded-2xl border border-border/70 bg-muted/20 px-4 py-3" className="rounded-2xl border border-border/70 bg-muted/20 px-4 py-3"
data-testid="channel-canvas-content" data-testid="channel-canvas-content"
> >
<Markdown channelNames={channelNames} content={canvasContent} /> <Markdown
channelNames={channelNames}
content={deferredCanvasContent ?? ""}
/>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -432,46 +432,47 @@ export function MembersSidebar({
function renderMemberCard(member: ChannelMember, memberIsBot: boolean) { function renderMemberCard(member: ChannelMember, memberIsBot: boolean) {
return ( return (
<MembersSidebarMemberCard <div className="content-visibility-auto" key={member.pubkey}>
canChangeRole={canManageMembers && member.pubkey !== currentPubkey} <MembersSidebarMemberCard
canRemoveMember={canRemoveMember(member)} canChangeRole={canManageMembers && member.pubkey !== currentPubkey}
isActionPending={isActionPending || changeRoleMutation.isPending} canRemoveMember={canRemoveMember(member)}
isArchived={isArchived} isActionPending={isActionPending || changeRoleMutation.isPending}
key={member.pubkey} isArchived={isArchived}
managedAgent={ managedAgent={
memberIsBot memberIsBot
? managedAgentByPubkey.get(normalizePubkey(member.pubkey)) ? managedAgentByPubkey.get(normalizePubkey(member.pubkey))
: undefined : undefined
} }
member={member} member={member}
memberIsBot={memberIsBot} memberIsBot={memberIsBot}
memberAvatarLabel={member.displayName ?? formatPubkey(member.pubkey)} memberAvatarLabel={member.displayName ?? formatPubkey(member.pubkey)}
memberLabel={formatMemberName(member, currentPubkey)} memberLabel={formatMemberName(member, currentPubkey)}
onChangeRole={(m, role) => { onChangeRole={(m, role) => {
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role }); void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
}} }}
onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined} onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined}
onManagedAgentAction={(agent) => { onManagedAgentAction={(agent) => {
void handleAgentLifecycleAction(agent); void handleAgentLifecycleAction(agent);
}} }}
onOpenProfile={handleOpenProfile} onOpenProfile={handleOpenProfile}
onRemoveMember={handleRemoveMember} onRemoveMember={handleRemoveMember}
onViewActivity={ onViewActivity={
onViewActivity onViewActivity
? (pubkey: string) => { ? (pubkey: string) => {
onOpenChange(false); onOpenChange(false);
onViewActivity(pubkey); onViewActivity(pubkey);
} }
: undefined : undefined
} }
presenceStatus={ presenceStatus={
memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null
} }
profileAvatarUrl={ profileAvatarUrl={
memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()] memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()]
?.avatarUrl ?? null ?.avatarUrl ?? null
} }
/> />
</div>
); );
} }

View File

@@ -1,10 +1,33 @@
import data from "@emoji-mart/data"; import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react"; import Picker from "@emoji-mart/react";
import { init } from "emoji-mart";
import * as React from "react"; import * as React from "react";
import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory"; import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory";
import { useCustomEmoji } from "@/features/custom-emoji/hooks"; 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 * 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, * an emoji — composing a message, reacting to a regular or system message,

View File

@@ -75,7 +75,10 @@ function ReplyRow({
const replyMentionNames = resolveMentionNames(reply.tags, profiles); const replyMentionNames = resolveMentionNames(reply.tags, profiles);
return ( 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"> <div className="flex items-center gap-2">
<UserProfilePopover pubkey={reply.pubkey}> <UserProfilePopover pubkey={reply.pubkey}>
<button <button

View File

@@ -7,6 +7,7 @@ import type { Channel } from "@/shared/api/types";
import { channelChrome } from "@/shared/layout/chromeLayout"; import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
import { Skeleton } from "@/shared/ui/skeleton"; import { Skeleton } from "@/shared/ui/skeleton";
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
import { import {
useCreateForumPostMutation, useCreateForumPostMutation,
@@ -47,6 +48,7 @@ export function ForumView({
targetReplyId, targetReplyId,
}: ForumViewProps) { }: ForumViewProps) {
const [isComposerOpen, setIsComposerOpen] = React.useState(false); const [isComposerOpen, setIsComposerOpen] = React.useState(false);
const postsScrollRef = React.useRef<HTMLDivElement>(null);
const profileQuery = useProfileQuery(); const profileQuery = useProfileQuery();
const postsQuery = useForumPostsQuery(channel); const postsQuery = useForumPostsQuery(channel);
@@ -185,6 +187,7 @@ export function ForumView({
<div <div
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto"
data-scroll-restoration-id={`forum-list:${channel.id}`} data-scroll-restoration-id={`forum-list:${channel.id}`}
ref={postsScrollRef}
> >
{postsQuery.isLoading ? ( {postsQuery.isLoading ? (
<div className="space-y-3 p-4"> <div className="space-y-3 p-4">
@@ -205,26 +208,32 @@ export function ForumView({
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-3 p-4"> <VirtualizedList
{posts.map((post) => ( estimateSize={120}
<ForumPostCard getItemKey={(post) => post.eventId}
canDelete={canDelete(post.pubkey, effectiveCurrentPubkey)} innerClassName="p-4"
currentPubkey={effectiveCurrentPubkey} items={posts}
isActive={selectedPostId === post.eventId} renderItem={(post) => (
isDeleting={ <div className="pb-3">
deletePostMutation.isPending && <ForumPostCard
deletePostMutation.variables?.eventId === post.eventId canDelete={canDelete(post.pubkey, effectiveCurrentPubkey)}
} currentPubkey={effectiveCurrentPubkey}
key={post.eventId} isActive={selectedPostId === post.eventId}
onClick={() => onSelectPost(post.eventId)} isDeleting={
onDelete={(eventId) => { deletePostMutation.isPending &&
deletePostMutation.mutate({ eventId }); deletePostMutation.variables?.eventId === post.eventId
}} }
post={post} onClick={() => onSelectPost(post.eventId)}
profiles={profiles} onDelete={(eventId) => {
/> deletePostMutation.mutate({ eventId });
))} }}
</div> post={post}
profiles={profiles}
/>
</div>
)}
scrollRef={postsScrollRef}
/>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { ChevronDown, Inbox } from "lucide-react"; import { ChevronDown, Inbox } from "lucide-react";
import * as React from "react";
import { import {
formatInboxTypeLabel, formatInboxTypeLabel,
@@ -18,6 +19,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu"; } from "@/shared/ui/dropdown-menu";
import { UserAvatar } from "@/shared/ui/UserAvatar"; import { UserAvatar } from "@/shared/ui/UserAvatar";
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [
{ value: "all", label: "All" }, { value: "all", label: "All" },
@@ -47,6 +49,91 @@ export function InboxListPane({
showRightDivider = false, showRightDivider = false,
}: InboxListPaneProps) { }: InboxListPaneProps) {
const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter); 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 ( return (
<section <section
@@ -101,6 +188,7 @@ export function InboxListPane({
<div <div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain" className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
data-testid="home-inbox-list" data-testid="home-inbox-list"
ref={scrollRef}
> >
{items.length === 0 ? ( {items.length === 0 ? (
<div className="flex h-full min-h-64 items-center justify-center px-6 text-center"> <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> </div>
) : ( ) : (
<div> <VirtualizedList
{items.map((item) => { estimateSize={76}
const isSelected = item.id === selectedId; getItemKey={(item) => item.id}
const isDone = doneSet.has(item.id); items={items}
const typeLabel = formatInboxTypeLabel(item); renderItem={renderItem}
scrollRef={scrollRef}
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>
)} )}
</div> </div>
</section> </section>

View File

@@ -29,6 +29,7 @@ import type { ChannelMember, UserProfileSummary } from "@/shared/api/types";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Skeleton } from "@/shared/ui/skeleton"; import { Skeleton } from "@/shared/ui/skeleton";
import { UserAvatar } from "@/shared/ui/UserAvatar"; import { UserAvatar } from "@/shared/ui/UserAvatar";
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
export type PulseTab = export type PulseTab =
| "search" | "search"
@@ -73,6 +74,7 @@ function TimelineSkeleton() {
export function PulseView({ currentPubkey }: PulseViewProps) { export function PulseView({ currentPubkey }: PulseViewProps) {
const [activeTab, setActiveTab] = React.useState<PulseTab>("everyone"); const [activeTab, setActiveTab] = React.useState<PulseTab>("everyone");
const [searchQuery, setSearchQuery] = React.useState(""); const [searchQuery, setSearchQuery] = React.useState("");
const scrollRef = React.useRef<HTMLDivElement>(null);
const contactListQuery = useContactListQuery(currentPubkey); const contactListQuery = useContactListQuery(currentPubkey);
const contacts = contactListQuery.data?.contacts ?? []; const contacts = contactListQuery.data?.contacts ?? [];
const contactPubkeys = React.useMemo( const contactPubkeys = React.useMemo(
@@ -268,43 +270,57 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
return agentNoteGroups.length === 0 ? ( return agentNoteGroups.length === 0 ? (
<EmptyState message={emptyMessages.agents} /> <EmptyState message={emptyMessages.agents} />
) : ( ) : (
agentNoteGroups.map((group) => ( <VirtualizedList
<AgentActivityCard estimateSize={160}
agentStatus={agentStatusMap[group.pubkey]} getItemKey={(group) => `${group.pubkey}-${group.latestAt}`}
group={group} items={agentNoteGroups}
key={`${group.pubkey}-${group.latestAt}`} renderItem={(group) => (
profile={profiles[group.pubkey.toLowerCase()] ?? null} <div className="pb-4">
/> <AgentActivityCard
)) agentStatus={agentStatusMap[group.pubkey]}
group={group}
profile={profiles[group.pubkey.toLowerCase()] ?? null}
/>
</div>
)}
scrollRef={scrollRef}
/>
); );
} }
return visibleNotes.length === 0 ? ( return visibleNotes.length === 0 ? (
<EmptyState message={emptyMessages[activeTab]} /> <EmptyState message={emptyMessages[activeTab]} />
) : ( ) : (
visibleNotes.map((note) => ( <VirtualizedList
<NoteCard estimateSize={140}
actions={{ getItemKey={(note) => note.id}
reply: noteActions.reply, items={visibleNotes}
share: noteActions.share, renderItem={(note) => (
startDm: noteActions.startDm, <div className="pb-4">
toggleUpvote: noteActions.toggleUpvote, <NoteCard
}} actions={{
composerProfiles={mentionProfiles} reply: noteActions.reply,
currentUserDisplayName={currentDisplayName} share: noteActions.share,
currentUserProfile={currentProfile} startDm: noteActions.startDm,
isAgent={agentPubkeySet.has(note.pubkey)} toggleUpvote: noteActions.toggleUpvote,
isOwnNote={note.pubkey === currentPubkey} }}
isReplySending={noteActions.isReplySending} composerProfiles={mentionProfiles}
isUpvotePending={noteActions.isUpvotePending(note.id)} currentUserDisplayName={currentDisplayName}
isUpvoted={noteActions.isUpvoted(note.id)} currentUserProfile={currentProfile}
reactionCount={noteActions.reactionCount(note.id)} isAgent={agentPubkeySet.has(note.pubkey)}
key={note.id} isOwnNote={note.pubkey === currentPubkey}
members={pulseMentionMembers} isReplySending={noteActions.isReplySending}
note={note} isUpvotePending={noteActions.isUpvotePending(note.id)}
profile={profiles[note.pubkey.toLowerCase()] ?? null} 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} 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 <div
aria-labelledby={pulseTabId(activeTab)} aria-labelledby={pulseTabId(activeTab)}
className={`mx-auto flex w-full max-w-2xl flex-col px-4 pb-10 sm:px-6 ${ 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> </div>
) : null} ) : null}
{activeTab !== "search" ? ( {activeTab !== "search" ? <div>{renderTimeline()}</div> : null}
<div className="space-y-4">{renderTimeline()}</div>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -36,6 +36,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu"; } from "@/shared/ui/dropdown-menu";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
type AssignableRelayRole = Exclude<RelayMemberRole, "owner">; type AssignableRelayRole = Exclude<RelayMemberRole, "owner">;
@@ -486,17 +487,22 @@ export function RelayMembersSettingsCard({
No members match your search. No members match your search.
</p> </p>
) : ( ) : (
<div className="max-h-[28rem] space-y-2 overflow-y-auto pr-1"> <VirtualizedList
{filteredMembers.map((member) => ( className="max-h-[28rem] pr-1"
<RelayMemberRow estimateSize={56}
currentPubkey={currentPubkey} getItemKey={(member) => member.pubkey}
currentRole={currentRole} items={filteredMembers}
key={member.pubkey} renderItem={(member) => (
member={member} <div className="pb-2">
profile={profiles?.[normalizePubkey(member.pubkey)]} <RelayMemberRow
/> currentPubkey={currentPubkey}
))} currentRole={currentRole}
</div> member={member}
profile={profiles?.[normalizePubkey(member.pubkey)]}
/>
</div>
)}
/>
)} )}
</div> </div>
</div> </div>

View File

@@ -363,7 +363,7 @@ export function ChannelGroupSection({
{items.map((channel) => ( {items.map((channel) => (
<ContextMenu key={channel.id}> <ContextMenu key={channel.id}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<SidebarMenuItem> <SidebarMenuItem className="content-visibility-auto-row">
{draggable ? ( {draggable ? (
<DraggableChannelRow channelId={channel.id}> <DraggableChannelRow channelId={channel.id}>
<ChannelMenuButton <ChannelMenuButton

View File

@@ -1768,6 +1768,23 @@
} }
@layer utilities { @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 { .buzz-huddle-tooltip {
--primary: var( --primary: var(
--huddle-tooltip-surface, --huddle-tooltip-surface,

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;
}

View File

@@ -2211,6 +2211,31 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
content: "Looks good to me. We should ship it.", content: "Looks good to me. We should ship it.",
sig: "mocksig".repeat(20).slice(0, 128), 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" : channelId === "94a444a4-c0a3-5966-ab05-530c6ddc2301"
? [ ? [
@@ -2522,7 +2547,9 @@ function getMockUserNotes(pubkey: string): RawUserNote[] {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
if (pubkey === DEFAULT_MOCK_IDENTITY.pubkey) { 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", id: "mock-note-launch",
pubkey, pubkey,
@@ -2538,6 +2565,14 @@ function getMockUserNotes(pubkey: string): RawUserNote[] {
tags: [], 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) { if (pubkey === ALICE_PUBKEY) {

View File

@@ -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` });
});
});

25
pnpm-lock.yaml generated
View File

@@ -90,6 +90,9 @@ importers:
'@tanstack/react-router': '@tanstack/react-router':
specifier: ^1.168.10 specifier: ^1.168.10
version: 1.170.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 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': '@tauri-apps/api':
specifier: ~2.11 specifier: ~2.11
version: 2.11.0 version: 2.11.0
@@ -452,14 +455,12 @@ packages:
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.4.16': '@biomejs/cli-linux-x64@2.4.16':
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==} resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.4.16': '@biomejs/cli-win32-arm64@2.4.16':
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==} resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
@@ -1549,6 +1550,12 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 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 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': '@tanstack/router-core@1.171.5':
resolution: {integrity: sha512-BfilbQqqWiQwJn68cD8wmk1ajEWIO3IlEA1zVuWslWbiVc23CDn+6ACO5tfPAcc96ED37hxela5ij3VBvAtusw==} resolution: {integrity: sha512-BfilbQqqWiQwJn68cD8wmk1ajEWIO3IlEA1zVuWslWbiVc23CDn+6ACO5tfPAcc96ED37hxela5ij3VBvAtusw==}
engines: {node: '>=20.19'} engines: {node: '>=20.19'}
@@ -1585,6 +1592,9 @@ packages:
'@tanstack/store@0.9.3': '@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} 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': '@tanstack/virtual-file-routes@1.162.0':
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
engines: {node: '>=20.19'} engines: {node: '>=20.19'}
@@ -1629,7 +1639,6 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2': '@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==} resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
@@ -3051,7 +3060,7 @@ packages:
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4205,6 +4214,12 @@ snapshots:
react-dom: 19.2.7(react@19.2.7) react-dom: 19.2.7(react@19.2.7)
use-sync-external-store: 1.6.0(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': '@tanstack/router-core@1.171.5':
dependencies: dependencies:
'@tanstack/history': 1.162.0 '@tanstack/history': 1.162.0
@@ -4262,6 +4277,8 @@ snapshots:
'@tanstack/store@0.9.3': {} '@tanstack/store@0.9.3': {}
'@tanstack/virtual-core@3.17.0': {}
'@tanstack/virtual-file-routes@1.162.0': {} '@tanstack/virtual-file-routes@1.162.0': {}
'@tauri-apps/api@2.11.0': {} '@tauri-apps/api@2.11.0': {}