perf(marketplace): lazy-mount grid cards + open the detail page on click

- LazyMount: mount a grid card only when it scrolls within 600px (skeleton-space
  placeholder otherwise), so a long feed no longer mounts every card + its
  hooks/DOM up front. Falls back to immediate render without IntersectionObserver.
- GearSwapItemCard: clicking the card now opens /listing/:pubkey/:identifier
  (was opening a DM). 'Contact Seller' still opens the DM. Card set to h-full so
  the lazy wrapper keeps the equal-height grid.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
SkateSpots 2026-09-26 23:08:01 +07:00
parent 269d5d6c09
commit 662a14bcd2
3 changed files with 71 additions and 9 deletions

View file

@ -127,12 +127,12 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
return ( return (
<Card <Card
className={`bg-black/90 border-2 group cursor-pointer transition-all hover:scale-[1.02] ${ className={`bg-black/90 border-2 group cursor-pointer transition-all hover:scale-[1.02] h-full ${
isWTB isWTB
? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]' ? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]'
: 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]' : 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]'
}`} }`}
onClick={() => navigate(`/messages/${npub}`)} onClick={() => navigate(`/listing/${item.pubkey}/${item.identifier}`)}
> >
<CardContent className="p-0"> <CardContent className="p-0">
{/* Image */} {/* Image */}

View file

@ -0,0 +1,60 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
/**
* Defer mounting `children` until the wrapper scrolls near the viewport.
*
* Long grids (e.g. the marketplace) otherwise mount every card — and every
* hook/query/DOM node each one pulls in — up front. With this, off-screen cards
* stay as a cheap placeholder until they're ~600px away, then mount once and
* stay mounted. Falls back to immediate render when IntersectionObserver is
* unavailable.
*/
export function LazyMount({
children,
minHeight = 300,
rootMargin = '600px',
className,
}: {
children: ReactNode;
/** Reserve height so the page doesn't jump when the card mounts. */
minHeight?: number;
rootMargin?: string;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (visible) return;
const el = ref.current;
if (!el) return;
if (typeof IntersectionObserver === 'undefined') {
setVisible(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin },
);
observer.observe(el);
return () => observer.disconnect();
}, [visible, rootMargin]);
return (
<div
ref={ref}
className={className}
style={visible ? undefined : { minHeight }}
aria-busy={!visible || undefined}
>
{visible ? children : null}
</div>
);
}

View file

@ -13,6 +13,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuCheckboxItem, DropdownMe
import { useMarketplaceItems } from '@/hooks/useMarketplace'; import { useMarketplaceItems } from '@/hooks/useMarketplace';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { LazyMount } from '@/components/LazyMount';
import { useBatchSellerSales } from '@/hooks/useSellerSales'; import { useBatchSellerSales } from '@/hooks/useSellerSales';
import { useFollowList } from '@/hooks/useFollowList'; import { useFollowList } from '@/hooks/useFollowList';
import { GearSwapItemCard } from '@/components/GearSwapItemCard'; import { GearSwapItemCard } from '@/components/GearSwapItemCard';
@ -410,13 +411,14 @@ export default function GearSwapPage() {
{!isLoading && !error && filteredItems.length > 0 && ( {!isLoading && !error && filteredItems.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<GearSwapItemCard <LazyMount key={item.id} className="h-full">
key={item.id} <GearSwapItemCard
item={item} item={item}
trustScore={trustScores?.get(item.pubkey)} trustScore={trustScores?.get(item.pubkey)}
salesCount={sellerSales?.get(item.pubkey) ?? 0} salesCount={sellerSales?.get(item.pubkey) ?? 0}
isFollowing={followingSet.has(item.pubkey)} isFollowing={followingSet.has(item.pubkey)}
/> />
</LazyMount>
))} ))}
</div> </div>
)} )}