perf: add export loading state and virtual scrolling for large lists

Export modal now shows a loading indicator and doesn't freeze the UI.
Large list modals use virtual scrolling for smooth performance.
This commit is contained in:
Usman Baig
2026-03-10 20:45:49 +01:00
parent 848bde237f
commit f10b903a80
9 changed files with 546 additions and 413 deletions

View File

@@ -7,6 +7,7 @@ import Image from 'next/image'
import { formatNumber } from '@ciphera-net/ui'
import { Modal, ArrowRightIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList'
import { getCampaigns, CampaignStat } from '@/lib/api/stats'
import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons'
import { Megaphone, FrameCornersIcon } from '@phosphor-icons/react'
@@ -225,7 +226,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="space-y-1 max-h-[80vh] overflow-y-auto pr-2">
<div className="max-h-[80vh]">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
@@ -246,7 +247,11 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
Export CSV
</button>
</div>
{filteredCampaigns.map((item) => (
<VirtualList
items={filteredCampaigns}
estimateSize={36}
className="max-h-[80vh] overflow-y-auto pr-2"
renderItem={(item) => (
<div
key={`${item.source}|${item.medium}|${item.campaign}`}
onClick={() => { if (onFilter) { onFilter({ dimension: 'utm_source', operator: 'is', values: [item.source] }); setIsModalOpen(false) } }}
@@ -277,7 +282,8 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
</span>
</div>
</div>
))}
)}
/>
</>
)
})()}

View File

@@ -9,6 +9,7 @@ import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/sta
import { FrameCornersIcon } from '@phosphor-icons/react'
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList'
import { type DimensionFilter } from '@/lib/filters'
interface ContentStatsProps {
@@ -209,7 +210,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="space-y-1 max-h-[80vh] overflow-y-auto pr-2">
<div className="max-h-[80vh]">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
@@ -217,7 +218,12 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
) : (() => {
const modalData = (fullData.length > 0 ? fullData : data).filter(p => !modalSearch || p.path.toLowerCase().includes(modalSearch.toLowerCase()))
const modalTotal = modalData.reduce((sum, p) => sum + p.pageviews, 0)
return modalData.map((page) => {
return (
<VirtualList
items={modalData}
estimateSize={36}
className="max-h-[80vh] overflow-y-auto pr-2"
renderItem={(page) => {
const canFilter = onFilter && page.path
return (
<div
@@ -238,7 +244,9 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
</div>
</div>
)
})
}}
/>
)
})()}
</div>
</Modal>

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useCallback } from 'react'
import { Modal, Button, Checkbox, Input, Select } from '@ciphera-net/ui'
import * as XLSX from 'xlsx'
import jsPDF from 'jspdf'
@@ -49,6 +49,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
const [format, setFormat] = useState<ExportFormat>('csv')
const [filename, setFilename] = useState(`pulse_export_${new Date().toISOString().split('T')[0]}`)
const [includeHeader, setIncludeHeader] = useState(true)
const [isExporting, setIsExporting] = useState(false)
const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({
date: true,
pageviews: true,
@@ -61,7 +62,12 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
setSelectedFields((prev) => ({ ...prev, [field]: checked }))
}
const handleExport = async () => {
const handleExport = () => {
setIsExporting(true)
// Let the browser paint the loading state before starting heavy work
requestAnimationFrame(() => {
setTimeout(async () => {
try {
// Filter fields
const fields = (Object.keys(selectedFields) as Array<keyof DailyStat>).filter((k) => selectedFields[k])
@@ -355,6 +361,13 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
document.body.removeChild(link)
onClose()
} catch (e) {
console.error('Export failed:', e)
} finally {
setIsExporting(false)
}
}, 0)
})
}
return (
@@ -440,11 +453,11 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<Button variant="secondary" onClick={onClose}>
<Button variant="secondary" onClick={onClose} disabled={isExporting}>
Cancel
</Button>
<Button variant="primary" onClick={handleExport}>
Export Data
<Button variant="primary" onClick={handleExport} disabled={isExporting}>
{isExporting ? 'Exporting...' : 'Export Data'}
</Button>
</div>
</div>

View File

@@ -13,6 +13,7 @@ const DottedMap = dynamic(() => import('./DottedMap'), { ssr: false })
const Globe = dynamic(() => import('./Globe'), { ssr: false })
import { Modal, GlobeIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList'
import { ShieldCheck, Detective, Broadcast, FrameCornersIcon } from '@phosphor-icons/react'
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
import { type DimensionFilter } from '@/lib/filters'
@@ -334,7 +335,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="space-y-1 max-h-[80vh] overflow-y-auto pr-2">
<div className="max-h-[80vh]">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
@@ -347,7 +348,12 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
return label.toLowerCase().includes(search)
})
const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0)
return modalData.map((item) => {
return (
<VirtualList
items={modalData}
estimateSize={36}
className="max-h-[80vh] overflow-y-auto pr-2"
renderItem={(item) => {
const dim = TAB_TO_DIMENSION[activeTab]
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
const canFilter = onFilter && dim && filterValue
@@ -375,7 +381,9 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
</div>
</div>
)
})
}}
/>
)
})()}
</div>
</Modal>

View File

@@ -9,6 +9,7 @@ import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
import { Monitor, FrameCornersIcon } from '@phosphor-icons/react'
import { Modal, GridIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList'
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
import { type DimensionFilter } from '@/lib/filters'
@@ -235,7 +236,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="space-y-1 max-h-[80vh] overflow-y-auto pr-2">
<div className="max-h-[80vh]">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
@@ -244,7 +245,12 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
const modalData = (fullData.length > 0 ? fullData : data).filter(item => !modalSearch || item.name.toLowerCase().includes(modalSearch.toLowerCase()))
const modalTotal = modalData.reduce((sum, item) => sum + item.pageviews, 0)
const dim = TAB_TO_DIMENSION[activeTab]
return modalData.map((item) => {
return (
<VirtualList
items={modalData}
estimateSize={36}
className="max-h-[80vh] overflow-y-auto pr-2"
renderItem={(item) => {
const canFilter = onFilter && dim
return (
<div
@@ -266,7 +272,9 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
</div>
</div>
)
})
}}
/>
)
})()}
</div>
</Modal>

View File

@@ -8,6 +8,7 @@ import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeRefer
import { FrameCornersIcon } from '@phosphor-icons/react'
import { Modal, GlobeIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import VirtualList from './VirtualList'
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
import { type DimensionFilter } from '@/lib/filters'
@@ -165,7 +166,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50"
/>
</div>
<div className="space-y-1 max-h-[80vh] overflow-y-auto pr-2">
<div className="max-h-[80vh]">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
@@ -173,7 +174,12 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
) : (() => {
const modalData = mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).filter(r => !modalSearch || getReferrerDisplayName(r.referrer).toLowerCase().includes(modalSearch.toLowerCase()))
const modalTotal = modalData.reduce((sum, r) => sum + r.pageviews, 0)
return modalData.map((ref) => (
return (
<VirtualList
items={modalData}
estimateSize={36}
className="max-h-[80vh] overflow-y-auto pr-2"
renderItem={(ref) => (
<div
key={ref.referrer}
onClick={() => { if (onFilter) { onFilter({ dimension: 'referrer', operator: 'is', values: [ref.referrer] }); setIsModalOpen(false) } }}
@@ -192,7 +198,9 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
</span>
</div>
</div>
))
)}
/>
)
})()}
</div>
</Modal>

View File

@@ -0,0 +1,53 @@
'use client'
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
interface VirtualListProps<T> {
items: T[]
estimateSize: number
className?: string
renderItem: (item: T, index: number) => React.ReactNode
}
export default function VirtualList<T>({ items, estimateSize, className, renderItem }: VirtualListProps<T>) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => estimateSize,
overscan: 10,
})
// For small lists (< 50 items), render directly without virtualization
if (items.length < 50) {
return (
<div className={className}>
{items.map((item, index) => renderItem(item, index))}
</div>
)
}
return (
<div ref={parentRef} className={className} style={{ overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderItem(items[virtualRow.index], virtualRow.index)}
</div>
))}
</div>
</div>
)
}

28
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@simplewebauthn/browser": "^13.2.2",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"@tanstack/react-virtual": "^3.13.21",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"cobe": "^0.6.5",
@@ -5405,6 +5406,33 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.21.tgz",
"integrity": "sha512-SYXFrmrbPgXBvf+HsOsKhFgqSe4M6B29VHOsX9Jih9TlNkNkDWx0hWMiMLUghMEzyUz772ndzdEeCEBx+3GIZw==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.21"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"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"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.21",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.21.tgz",
"integrity": "sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",

View File

@@ -18,6 +18,7 @@
"@simplewebauthn/browser": "^13.2.2",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"@tanstack/react-virtual": "^3.13.21",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"cobe": "^0.6.5",