Skip to content

Commit 68e11e2

Browse files
committed
fix: virtualise package lists + use infinite scrolling
1 parent c979fbf commit 68e11e2

7 files changed

Lines changed: 499 additions & 164 deletions

File tree

app/components/PackageList.vue

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,123 @@
11
<script setup lang="ts">
22
import type { NpmSearchResult } from '#shared/types'
3+
import type { WindowVirtualizerHandle } from '~/composables/useVirtualInfiniteScroll'
4+
import { WindowVirtualizer } from 'virtua/vue'
35
4-
defineProps<{
6+
const props = defineProps<{
57
/** List of search results to display */
68
results: NpmSearchResult[]
79
/** Heading level for package names */
810
headingLevel?: 'h2' | 'h3'
911
/** Whether to show publisher username on cards */
1012
showPublisher?: boolean
13+
/** Whether there are more items to load */
14+
hasMore?: boolean
15+
/** Whether currently loading more items */
16+
isLoading?: boolean
17+
/** Page size for tracking current page */
18+
pageSize?: number
19+
/** Initial page to scroll to (1-indexed) */
20+
initialPage?: number
1121
}>()
22+
23+
const emit = defineEmits<{
24+
/** Emitted when scrolled near the bottom and more items should be loaded */
25+
loadMore: []
26+
/** Emitted when the visible page changes */
27+
pageChange: [page: number]
28+
}>()
29+
30+
// Reference to WindowVirtualizer for infinite scroll detection
31+
const listRef = ref<WindowVirtualizerHandle>()
32+
33+
// Set up infinite scroll if hasMore is provided
34+
const hasMore = computed(() => props.hasMore ?? false)
35+
const isLoading = computed(() => props.isLoading ?? false)
36+
const itemCount = computed(() => props.results.length)
37+
const pageSize = computed(() => props.pageSize ?? 20)
38+
39+
const { handleScroll, scrollToPage } = useVirtualInfiniteScroll({
40+
listRef,
41+
itemCount,
42+
hasMore,
43+
isLoading,
44+
pageSize: pageSize.value,
45+
threshold: 5,
46+
onLoadMore: () => emit('loadMore'),
47+
onPageChange: page => emit('pageChange', page),
48+
})
49+
50+
// Scroll to initial page once list is ready and has items
51+
const hasScrolledToInitial = ref(false)
52+
53+
watch(
54+
[() => props.results.length, () => props.initialPage, listRef],
55+
([length, initialPage, list]) => {
56+
if (!hasScrolledToInitial.value && list && length > 0 && initialPage && initialPage > 1) {
57+
// Wait for next tick to ensure list is rendered
58+
nextTick(() => {
59+
scrollToPage(initialPage)
60+
hasScrolledToInitial.value = true
61+
})
62+
}
63+
},
64+
{ immediate: true },
65+
)
66+
67+
// Reset scroll state when results change significantly (new search)
68+
watch(
69+
() => props.results,
70+
(newResults, oldResults) => {
71+
// If this looks like a new search (different first item or much shorter), reset
72+
if (
73+
!oldResults ||
74+
newResults.length === 0 ||
75+
(oldResults.length > 0 && newResults[0]?.package.name !== oldResults[0]?.package.name)
76+
) {
77+
hasScrolledToInitial.value = false
78+
}
79+
},
80+
)
1281
</script>
1382

1483
<template>
15-
<ol class="space-y-3 list-none m-0 p-0">
16-
<li
17-
v-for="(result, index) in results"
18-
:key="result.package.name"
19-
class="animate-fade-in animate-fill-both"
20-
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
84+
<div>
85+
<WindowVirtualizer
86+
ref="listRef"
87+
:data="results"
88+
:item-size="140"
89+
as="ol"
90+
item="li"
91+
class="list-none m-0 p-0"
92+
@scroll="handleScroll"
93+
>
94+
<template #default="{ item, index }">
95+
<div class="pb-4">
96+
<PackageCard
97+
:result="item as NpmSearchResult"
98+
:heading-level="headingLevel"
99+
:show-publisher="showPublisher"
100+
class="animate-fade-in animate-fill-both"
101+
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
102+
/>
103+
</div>
104+
</template>
105+
</WindowVirtualizer>
106+
107+
<!-- Loading indicator -->
108+
<div v-if="isLoading" class="py-4 flex items-center justify-center">
109+
<div class="flex items-center gap-3 text-fg-muted font-mono text-sm">
110+
<span class="w-4 h-4 border-2 border-fg-subtle border-t-fg rounded-full animate-spin" />
111+
Loading more...
112+
</div>
113+
</div>
114+
115+
<!-- End of results -->
116+
<p
117+
v-else-if="!hasMore && results.length > 0"
118+
class="py-4 text-center text-fg-subtle font-mono text-sm"
21119
>
22-
<PackageCard :result="result" :heading-level="headingLevel" :show-publisher="showPublisher" />
23-
</li>
24-
</ol>
120+
End of results
121+
</p>
122+
</div>
25123
</template>
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { Ref } from 'vue'
2+
3+
export interface WindowVirtualizerHandle {
4+
readonly scrollOffset: number
5+
readonly viewportSize: number
6+
findItemIndex: (offset: number) => number
7+
getItemOffset: (index: number) => number
8+
scrollToIndex: (index: number, opts?: { align?: 'start' | 'center' | 'end' }) => void
9+
}
10+
11+
export interface UseVirtualInfiniteScrollOptions {
12+
/** Reference to the WindowVirtualizer component */
13+
listRef: Ref<WindowVirtualizerHandle | undefined>
14+
/** Current item count */
15+
itemCount: Ref<number>
16+
/** Whether there are more items to load */
17+
hasMore: Ref<boolean>
18+
/** Whether currently loading */
19+
isLoading: Ref<boolean>
20+
/** Page size for calculating current page */
21+
pageSize: number
22+
/** Threshold in items before end to trigger load */
23+
threshold?: number
24+
/** Callback to load more items */
25+
onLoadMore: () => void
26+
/** Callback when visible page changes (for URL updates) */
27+
onPageChange?: (page: number) => void
28+
}
29+
30+
/**
31+
* Composable for handling infinite scroll with virtua's WindowVirtualizer
32+
* Detects when user scrolls near the end and triggers loading more items
33+
* Also tracks current visible page for URL persistence
34+
*/
35+
export function useVirtualInfiniteScroll(options: UseVirtualInfiniteScrollOptions) {
36+
const {
37+
listRef,
38+
itemCount,
39+
hasMore,
40+
isLoading,
41+
pageSize,
42+
threshold = 5,
43+
onLoadMore,
44+
onPageChange,
45+
} = options
46+
47+
// Track last fetched count to prevent duplicate fetches
48+
const fetchedCountRef = ref(-1)
49+
50+
// Track current page to avoid unnecessary updates
51+
const currentPage = ref(1)
52+
53+
function handleScroll() {
54+
const list = listRef.value
55+
if (!list) return
56+
57+
// Calculate current visible page based on first visible item
58+
const startIndex = list.findItemIndex(list.scrollOffset)
59+
const newPage = Math.floor(startIndex / pageSize) + 1
60+
61+
if (newPage !== currentPage.value && onPageChange) {
62+
currentPage.value = newPage
63+
onPageChange(newPage)
64+
}
65+
66+
// Don't fetch if already loading or no more items
67+
if (isLoading.value || !hasMore.value) return
68+
69+
// Don't fetch if we already fetched at this count
70+
const count = itemCount.value
71+
if (fetchedCountRef.value >= count) return
72+
73+
// Check if we're near the end
74+
const endOffset = list.scrollOffset + list.viewportSize
75+
const endIndex = list.findItemIndex(endOffset)
76+
77+
if (endIndex + threshold >= count) {
78+
fetchedCountRef.value = count
79+
onLoadMore()
80+
}
81+
}
82+
83+
/**
84+
* Scroll to a specific page (1-indexed)
85+
* Call this after data is loaded to restore scroll position
86+
*/
87+
function scrollToPage(page: number) {
88+
const list = listRef.value
89+
if (!list || page < 1) return
90+
91+
const targetIndex = (page - 1) * pageSize
92+
list.scrollToIndex(targetIndex, { align: 'start' })
93+
currentPage.value = page
94+
}
95+
96+
// Reset state when item count changes significantly (new search)
97+
watch(itemCount, (newCount, oldCount) => {
98+
// If count decreased or reset, clear the fetched tracking
99+
if (newCount < oldCount || newCount === 0) {
100+
fetchedCountRef.value = -1
101+
currentPage.value = 1
102+
}
103+
})
104+
105+
return {
106+
handleScroll,
107+
scrollToPage,
108+
currentPage,
109+
}
110+
}

app/pages/@[org].vue

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,57 @@
11
<script setup lang="ts">
22
import { formatNumber } from '#imports'
3+
import { debounce } from 'perfect-debounce'
34
45
definePageMeta({
56
name: 'org',
67
alias: ['/org/:org()'],
78
})
89
910
const route = useRoute('org')
11+
const router = useRouter()
1012
1113
const orgName = computed(() => route.params.org)
1214
1315
const { isConnected } = useConnector()
1416
17+
// Infinite scroll state
18+
const pageSize = 50
19+
const loadedPages = ref(1)
20+
const isLoadingMore = ref(false)
21+
22+
// Get initial page from URL (for scroll restoration on reload)
23+
const initialPage = computed(() => {
24+
const p = Number.parseInt(route.query.page as string, 10)
25+
return Number.isNaN(p) ? 1 : Math.max(1, p)
26+
})
27+
28+
// Debounced URL update for page
29+
const updateUrlPage = debounce((page: number) => {
30+
router.replace({
31+
query: {
32+
...route.query,
33+
page: page > 1 ? page : undefined,
34+
},
35+
})
36+
}, 500)
37+
1538
// Search for packages in this org's scope (@orgname/*)
1639
const searchQuery = computed(() => `@${orgName.value}`)
1740
18-
const { data: results, status, error } = useNpmSearch(searchQuery, { size: 250 })
41+
const {
42+
data: results,
43+
status,
44+
error,
45+
} = useNpmSearch(searchQuery, () => ({
46+
size: pageSize * loadedPages.value,
47+
}))
48+
49+
// Initialize loaded pages from URL on mount
50+
onMounted(() => {
51+
if (initialPage.value > 1) {
52+
loadedPages.value = initialPage.value
53+
}
54+
})
1955
2056
// Filter to only include packages that are actually in this scope
2157
// (search may return packages that just mention the org name)
@@ -29,6 +65,37 @@ const scopedPackages = computed(() => {
2965
3066
const packageCount = computed(() => scopedPackages.value.length)
3167
68+
// Check if there are potentially more results
69+
const hasMore = computed(() => {
70+
if (!results.value) return false
71+
// npm search API returns max 250 results, but we paginate for faster initial load
72+
return (
73+
results.value.objects.length >= pageSize * loadedPages.value &&
74+
loadedPages.value * pageSize < 250
75+
)
76+
})
77+
78+
function loadMore() {
79+
if (isLoadingMore.value || !hasMore.value) return
80+
81+
isLoadingMore.value = true
82+
loadedPages.value++
83+
84+
nextTick(() => {
85+
isLoadingMore.value = false
86+
})
87+
}
88+
89+
// Update URL when page changes from scrolling
90+
function handlePageChange(page: number) {
91+
updateUrlPage(page)
92+
}
93+
94+
// Reset pagination when org changes
95+
watch(orgName, () => {
96+
loadedPages.value = 1
97+
})
98+
3299
const activeTab = ref<'members' | 'teams'>('members')
33100
34101
// Canonical URL for this org page
@@ -124,7 +191,7 @@ defineOgImageComponent('Default', {
124191
</ClientOnly>
125192

126193
<!-- Loading state -->
127-
<LoadingSpinner v-if="status === 'pending'" text="Loading packages..." />
194+
<LoadingSpinner v-if="status === 'pending' && loadedPages === 1" text="Loading packages..." />
128195

129196
<!-- Error state -->
130197
<div v-else-if="status === 'error'" role="alert" class="py-12 text-center">
@@ -148,7 +215,15 @@ defineOgImageComponent('Default', {
148215
<section v-else-if="scopedPackages.length > 0" aria-label="Organization packages">
149216
<h2 class="text-xs text-fg-subtle uppercase tracking-wider mb-4">Packages</h2>
150217

151-
<PackageList :results="scopedPackages" />
218+
<PackageList
219+
:results="scopedPackages"
220+
:has-more="hasMore"
221+
:is-loading="isLoadingMore || (status === 'pending' && loadedPages > 1)"
222+
:page-size="pageSize"
223+
:initial-page="initialPage"
224+
@load-more="loadMore"
225+
@page-change="handlePageChange"
226+
/>
152227
</section>
153228
</main>
154229
</template>

0 commit comments

Comments
 (0)