diff --git a/src/components/ProductTable/Filter.tsx b/src/components/ProductTable/Filter.tsx
index 37ee1e48e26..6687cdcc2af 100644
--- a/src/components/ProductTable/Filter.tsx
+++ b/src/components/ProductTable/Filter.tsx
@@ -14,6 +14,37 @@ interface FilterProps {
onChange: (updatedFilter: FilterOption) => void
}
+const arePropsEqual = (prevProps: FilterProps, nextProps: FilterProps) => {
+ if (prevProps.filterIndex !== nextProps.filterIndex) return false
+ if (prevProps.filter.title !== nextProps.filter.title) return false
+ if (prevProps.filter.showFilterOption !== nextProps.filter.showFilterOption)
+ return false
+
+ const prevItems = prevProps.filter.items
+ const nextItems = nextProps.filter.items
+
+ if (prevItems.length !== nextItems.length) return false
+
+ for (let i = 0; i < prevItems.length; i++) {
+ const prevItem = prevItems[i]
+ const nextItem = nextItems[i]
+
+ if (prevItem.filterKey !== nextItem.filterKey) return false
+ if (prevItem.inputState !== nextItem.inputState) return false
+
+ if (prevItem.options.length !== nextItem.options.length) return false
+
+ for (let j = 0; j < prevItem.options.length; j++) {
+ if (prevItem.options[j].filterKey !== nextItem.options[j].filterKey)
+ return false
+ if (prevItem.options[j].inputState !== nextItem.options[j].inputState)
+ return false
+ }
+ }
+
+ return true
+}
+
const Filter = ({ filter, filterIndex, onChange }: FilterProps) => {
const handleChange = (
_: number,
@@ -110,4 +141,4 @@ const Filter = ({ filter, filterIndex, onChange }: FilterProps) => {
)
}
-export default memo(Filter)
+export default memo(Filter, arePropsEqual)
diff --git a/src/components/ProductTable/MobileFilters.tsx b/src/components/ProductTable/MobileFilters.tsx
index 3ca6714b111..88d7d315d46 100644
--- a/src/components/ProductTable/MobileFilters.tsx
+++ b/src/components/ProductTable/MobileFilters.tsx
@@ -5,16 +5,8 @@ import { FilterOption, TPresetFilters } from "@/lib/types"
import Filters from "@/components/ProductTable/Filters"
import PresetFilters from "@/components/ProductTable/PresetFilters"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
+import { PersistentPanel } from "@/components/ui/persistent-panel"
+import { Sheet, SheetTrigger } from "@/components/ui/sheet"
import { trackCustomEvent } from "@/lib/utils/matomo"
@@ -49,21 +41,23 @@ const MobileFilters = ({
}: MobileFiltersProps) => {
const { t } = useTranslation("table")
+ const handleOpenChange = (open: boolean) => {
+ setMobileFiltersOpen(open)
+ trackCustomEvent({
+ eventCategory: "MobileFilterToggle",
+ eventAction: "Tap MobileFilterToggle",
+ eventName: `show mobile filters ${open}`,
+ })
+ }
+
+ const handleClose = () => {
+ handleOpenChange(false)
+ }
+
return (
- {
- setMobileFiltersOpen(open)
- trackCustomEvent({
- eventCategory: "MobileFilterToggle",
- eventAction: "Tap MobileFilterToggle",
- eventName: `show mobile filters ${open}`,
- })
- }}
- >
-
+
+
-
-
-
-
-
)
}
diff --git a/src/components/ProductTable/index.tsx b/src/components/ProductTable/index.tsx
index 0467c21eeb2..5afba62ad64 100644
--- a/src/components/ProductTable/index.tsx
+++ b/src/components/ProductTable/index.tsx
@@ -1,4 +1,11 @@
-import { useCallback, useEffect, useMemo, useState } from "react"
+import {
+ startTransition,
+ useCallback,
+ useDeferredValue,
+ useEffect,
+ useMemo,
+ useState,
+} from "react"
import { useSearchParams } from "next/navigation"
import type { FilterOption, TPresetFilters } from "@/lib/types"
@@ -77,15 +84,17 @@ const ProductTable = ({
const updateFilters = useCallback(
(filters: FilterOption | FilterOption[]) => {
- setFilters((prevFilters) => {
- return prevFilters.map((prevFilter) => {
- const filter = Array.isArray(filters)
- ? filters.find((f) => f.title === prevFilter.title)
- : filters.title === prevFilter.title
- ? filters
- : prevFilter
- if (!filter) return prevFilter
- return filter
+ startTransition(() => {
+ setFilters((prevFilters) => {
+ return prevFilters.map((prevFilter) => {
+ const filter = Array.isArray(filters)
+ ? filters.find((f) => f.title === prevFilter.title)
+ : filters.title === prevFilter.title
+ ? filters
+ : prevFilter
+ if (!filter) return prevFilter
+ return filter
+ })
})
})
},
@@ -93,7 +102,9 @@ const ProductTable = ({
)
const resetFilters = useCallback(() => {
- setFilters(initialFilters)
+ startTransition(() => {
+ setFilters(initialFilters)
+ })
onResetFilters?.()
}, [initialFilters, onResetFilters])
@@ -114,10 +125,7 @@ const ProductTable = ({
})
}, [filteredData, presetFilters])
- const activeFiltersCount = useMemo(
- () => getActiveFiltersCount(filters),
- [filters]
- )
+ const activeFiltersCount = useDeferredValue(getActiveFiltersCount(filters))
return (
diff --git a/src/components/ui/persistent-panel.tsx b/src/components/ui/persistent-panel.tsx
new file mode 100644
index 00000000000..c65d8ddcc71
--- /dev/null
+++ b/src/components/ui/persistent-panel.tsx
@@ -0,0 +1,141 @@
+import * as React from "react"
+import { createPortal } from "react-dom"
+
+import { cn } from "@/lib/utils/cn"
+
+interface PersistentPanelProps {
+ open: boolean
+ side?: "left" | "right" | "top" | "bottom"
+ className?: string
+ children: React.ReactNode
+ onOpenChange?: (open: boolean) => void
+}
+
+/**
+ * PersistentPanel keeps content mounted after first render to avoid expensive
+ * re-renders. It controls visibility with CSS instead of mounting/unmounting.
+ *
+ * Use this as an alternative to Sheet, Dialog, or Drawer content when the
+ * content is expensive to render (e.g., complex filter forms, large lists).
+ *
+ * Features:
+ * - Lazy mount: only renders after first open
+ * - Stays mounted: avoids re-render cost on subsequent opens
+ * - Animated: slide-in/out transitions work correctly
+ * - Accessible: escape key, overlay click, scroll lock, aria attributes
+ */
+const PersistentPanel = ({
+ open,
+ side = "left",
+ className,
+ children,
+ onOpenChange,
+}: PersistentPanelProps) => {
+ // Track if component should be in DOM (lazy mount, stays mounted after first open)
+ const [isMounted, setIsMounted] = React.useState(false)
+ // Track CSS visibility state for animations (separate from open to allow animation timing)
+ const [showContent, setShowContent] = React.useState(false)
+
+ const overlayRef = React.useRef
(null)
+
+ // Mount component on first open
+ React.useEffect(() => {
+ if (open && !isMounted) {
+ setIsMounted(true)
+ }
+ }, [open, isMounted])
+
+ // Handle animation timing - runs after isMounted changes
+ React.useEffect(() => {
+ if (!isMounted) return
+
+ if (open) {
+ // Small delay ensures browser paints the hidden state first,
+ // allowing the CSS transition to animate
+ const timer = setTimeout(() => {
+ setShowContent(true)
+ }, 20)
+ return () => clearTimeout(timer)
+ } else {
+ // Close immediately to trigger exit animation
+ setShowContent(false)
+ }
+ }, [open, isMounted])
+
+ // Handle overlay click to close
+ const handleOverlayClick = (e: React.MouseEvent) => {
+ if (e.target === overlayRef.current && onOpenChange) {
+ onOpenChange(false)
+ }
+ }
+
+ // Handle escape key
+ React.useEffect(() => {
+ if (!isMounted || !open) return
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && onOpenChange) {
+ onOpenChange(false)
+ }
+ }
+
+ document.addEventListener("keydown", handleEscape)
+ return () => document.removeEventListener("keydown", handleEscape)
+ }, [isMounted, open, onOpenChange])
+
+ // Lock body scroll when panel is visible
+ React.useEffect(() => {
+ if (!isMounted || !showContent) return
+
+ const originalOverflow = document.body.style.overflow
+ document.body.style.overflow = "hidden"
+ return () => {
+ document.body.style.overflow = originalOverflow
+ }
+ }, [showContent, isMounted])
+
+ // Don't render until first open
+ if (!isMounted) {
+ return null
+ }
+
+ const overlayClasses = cn(
+ "fixed inset-0 z-modal bg-gray-800 transition-opacity duration-300",
+ showContent ? "opacity-70" : "opacity-0 pointer-events-none"
+ )
+
+ const contentClasses = cn(
+ "fixed z-modal bg-background shadow-xl transition-transform duration-300 ease-in-out flex h-full flex-col p-2",
+ side === "left" && "inset-y-0 left-0 h-full w-full sm:max-w-lg",
+ side === "right" && "inset-y-0 right-0 h-full w-full sm:max-w-lg",
+ side === "top" && "inset-x-0 top-0",
+ side === "bottom" && "inset-x-0 bottom-0",
+ // Slide animations based on visibility
+ side === "left" && (showContent ? "translate-x-0" : "-translate-x-full"),
+ side === "right" && (showContent ? "translate-x-0" : "translate-x-full"),
+ side === "top" && (showContent ? "translate-y-0" : "-translate-y-full"),
+ side === "bottom" && (showContent ? "translate-y-0" : "translate-y-full"),
+ className
+ )
+
+ return createPortal(
+ <>
+ {/* Overlay */}
+
+
+ {/* Content */}
+
+ {children}
+
+ >,
+ document.body
+ )
+}
+PersistentPanel.displayName = "PersistentPanel"
+
+export { PersistentPanel }
diff --git a/src/lib/utils/wallets.ts b/src/lib/utils/wallets.ts
index 4f62afc490b..741c6b021b4 100644
--- a/src/lib/utils/wallets.ts
+++ b/src/lib/utils/wallets.ts
@@ -196,27 +196,30 @@ export const filterFn = (data: WalletRow[], filters: FilterOption[]) => {
const activeFilterKeys = getActiveFilterKeys(filters)
- filters.forEach((filter) => {
- filter.items.forEach((item) => {
+ for (const filter of filters) {
+ for (const item of filter.items) {
if (item.filterKey === "languages") {
selectedLanguage = item.inputState as string
} else if (item.filterKey === "layer_2_support") {
selectedLayer2 = (item.inputState as ChainName[]) || []
}
- })
- })
+ }
+ }
- return data
- .filter((item) => {
- return item.languages_supported.includes(selectedLanguage as Lang)
- })
- .filter((item) => {
- return (
- selectedLayer2.length === 0 ||
- selectedLayer2.every((chain) => item.supported_chains.includes(chain))
- )
- })
- .filter((item) => {
- return activeFilterKeys.every((key) => item[key])
- })
+ return data.filter((wallet) => {
+ // Check language support
+ const matchesLanguage = wallet.languages_supported.includes(
+ selectedLanguage as Lang
+ )
+
+ // Check layer 2 support (empty array means no filter applied)
+ const matchesLayer2 =
+ selectedLayer2.length === 0 ||
+ selectedLayer2.every((chain) => wallet.supported_chains.includes(chain))
+
+ // Check active filter keys
+ const matchesActiveFilters = activeFilterKeys.every((key) => wallet[key])
+
+ return matchesLanguage && matchesLayer2 && matchesActiveFilters
+ })
}