Skip to content

Commit 8949880

Browse files
Adebesin-Cellclaude
andcommitted
refactor: use useVisibleItems composable for org page
Replace the custom progressive loading state (hasMore, isLoadingMore, loadAll, cache) with the useVisibleItems composable from #2395. - useOrgPackages now fetches all packages upfront via batched Algolia - useVisibleItems controls display (first 250 visible, expand on demand) - Filtering/sorting triggers expand() to reveal all items - Net -147 lines removed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5686329 commit 8949880

File tree

2 files changed

+34
-189
lines changed

2 files changed

+34
-189
lines changed

app/composables/npm/useOrgPackages.ts

Lines changed: 21 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,27 @@ import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#s
22
import { emptySearchResponse, metaToSearchResult } from './search-utils'
33
import { mapWithConcurrency } from '#shared/utils/async'
44

5-
/** Number of packages to fetch metadata for in the initial load */
6-
const INITIAL_BATCH_SIZE = 250
7-
85
/** Max names per Algolia getObjects request */
96
const ALGOLIA_BATCH_SIZE = 1000
107

118
export interface OrgPackagesResponse extends NpmSearchResponse {
12-
/** Total number of packages in the org (may exceed objects.length if not all loaded yet) */
9+
/** Total number of packages in the org */
1310
totalPackages: number
14-
/** Whether there are more packages that haven't been loaded yet */
15-
isTruncated: boolean
1611
}
1712

1813
function emptyOrgResponse(): OrgPackagesResponse {
1914
return {
2015
...emptySearchResponse(),
2116
totalPackages: 0,
22-
isTruncated: false,
2317
}
2418
}
2519

2620
/**
27-
* Fetch packages for an npm organization with progressive loading.
21+
* Fetch all packages for an npm organization.
2822
*
2923
* 1. Gets the authoritative package list from the npm registry (single request)
30-
* 2. Fetches metadata for the first batch immediately
31-
* 3. Remaining packages are loaded on-demand via `loadAll()`
24+
* 2. Fetches metadata from Algolia in batches (max 1000 per request)
25+
* 3. Falls back to lightweight server-side package-meta lookups
3226
*/
3327
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
3428
const route = useRoute()
@@ -40,24 +34,6 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
4034
})
4135
const { getPackagesByNameSlice } = useAlgoliaSearch()
4236

43-
// --- Progressive loading state ---
44-
const cache = shallowRef<{
45-
org: string
46-
allNames: string[]
47-
objects: NpmSearchResult[]
48-
totalPackages: number
49-
} | null>(null)
50-
51-
const isLoadingMore = shallowRef(false)
52-
53-
const hasMore = computed(() => {
54-
if (!cache.value) return false
55-
return cache.value.objects.length < cache.value.allNames.length
56-
})
57-
58-
// Promise lock to prevent duplicate loadAll calls
59-
let loadAllPromise: Promise<void> | null = null
60-
6137
const asyncData = useLazyAsyncData(
6238
() => `org-packages:${searchProviderValue.value}:${toValue(orgName)}`,
6339
async ({ ssrContext }, { signal }) => {
@@ -90,19 +66,24 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
9066
}
9167

9268
if (packageNames.length === 0) {
93-
cache.value = { org, allNames: [], objects: [], totalPackages: 0 }
9469
return emptyOrgResponse()
9570
}
9671

9772
const totalPackages = packageNames.length
98-
const initialNames = packageNames.slice(0, INITIAL_BATCH_SIZE)
9973

100-
// Fetch metadata for first batch
101-
let initialObjects: NpmSearchResult[] = []
74+
// Fetch metadata from Algolia in batches
75+
let objects: NpmSearchResult[] = []
10276

10377
if (searchProviderValue.value === 'algolia') {
10478
try {
105-
initialObjects = await getPackagesByNameSlice(initialNames)
79+
const batches: string[][] = []
80+
for (let i = 0; i < packageNames.length; i += ALGOLIA_BATCH_SIZE) {
81+
batches.push(packageNames.slice(i, i + ALGOLIA_BATCH_SIZE))
82+
}
83+
84+
const results = await Promise.all(batches.map(batch => getPackagesByNameSlice(batch)))
85+
86+
objects = results.flat()
10687
} catch {
10788
// Fall through to npm fallback
10889
}
@@ -111,10 +92,10 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
11192
// Staleness guard
11293
if (toValue(orgName) !== org) return emptyOrgResponse()
11394

114-
// npm fallback for initial batch
115-
if (initialObjects.length === 0) {
95+
// npm fallback
96+
if (objects.length === 0) {
11697
const metaResults = await mapWithConcurrency(
117-
initialNames,
98+
packageNames,
11899
async name => {
119100
try {
120101
return await $fetch<PackageMetaResponse>(
@@ -130,152 +111,21 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
130111

131112
if (toValue(orgName) !== org) return emptyOrgResponse()
132113

133-
initialObjects = metaResults
114+
objects = metaResults
134115
.filter((meta): meta is PackageMetaResponse => meta !== null)
135116
.map(metaToSearchResult)
136117
}
137118

138-
cache.value = {
139-
org,
140-
allNames: packageNames,
141-
objects: initialObjects,
142-
totalPackages,
143-
}
144-
145119
return {
146120
isStale: false,
147-
objects: initialObjects,
148-
total: initialObjects.length,
121+
objects,
122+
total: objects.length,
149123
totalPackages,
150-
isTruncated: packageNames.length > initialObjects.length,
151124
time: new Date().toISOString(),
152125
} satisfies OrgPackagesResponse
153126
},
154127
{ default: emptyOrgResponse },
155128
)
156129

157-
/** Load all remaining packages that weren't fetched in the initial batch */
158-
async function loadAll(): Promise<void> {
159-
if (!hasMore.value) return
160-
161-
// Reuse existing promise if already running
162-
if (loadAllPromise) {
163-
await loadAllPromise
164-
return
165-
}
166-
167-
loadAllPromise = _doLoadAll()
168-
try {
169-
await loadAllPromise
170-
} finally {
171-
loadAllPromise = null
172-
}
173-
}
174-
175-
async function _doLoadAll(): Promise<void> {
176-
const currentCache = cache.value
177-
if (!currentCache || currentCache.objects.length >= currentCache.allNames.length) return
178-
179-
const org = currentCache.org
180-
isLoadingMore.value = true
181-
182-
try {
183-
const remainingNames = currentCache.allNames.slice(currentCache.objects.length)
184-
185-
if (searchProviderValue.value === 'algolia') {
186-
// Split remaining into batches and fetch in parallel
187-
const batches: string[][] = []
188-
for (let i = 0; i < remainingNames.length; i += ALGOLIA_BATCH_SIZE) {
189-
batches.push(remainingNames.slice(i, i + ALGOLIA_BATCH_SIZE))
190-
}
191-
192-
const results = await Promise.allSettled(
193-
batches.map(batch => getPackagesByNameSlice(batch)),
194-
)
195-
196-
if (toValue(orgName) !== org) return
197-
198-
const newObjects: NpmSearchResult[] = []
199-
for (const result of results) {
200-
if (result.status === 'fulfilled') {
201-
newObjects.push(...result.value)
202-
}
203-
}
204-
205-
if (newObjects.length > 0) {
206-
const existingNames = new Set(currentCache.objects.map(o => o.package.name))
207-
const deduped = newObjects.filter(o => !existingNames.has(o.package.name))
208-
cache.value = {
209-
...currentCache,
210-
objects: [...currentCache.objects, ...deduped],
211-
}
212-
}
213-
} else {
214-
// npm fallback: fetch with concurrency
215-
const metaResults = await mapWithConcurrency(
216-
remainingNames,
217-
async name => {
218-
try {
219-
return await $fetch<PackageMetaResponse>(
220-
`/api/registry/package-meta/${encodePackageName(name)}`,
221-
)
222-
} catch {
223-
return null
224-
}
225-
},
226-
10,
227-
)
228-
229-
if (toValue(orgName) !== org) return
230-
231-
const newObjects = metaResults
232-
.filter((meta): meta is PackageMetaResponse => meta !== null)
233-
.map(metaToSearchResult)
234-
235-
if (newObjects.length > 0) {
236-
const existingNames = new Set(currentCache.objects.map(o => o.package.name))
237-
const deduped = newObjects.filter(o => !existingNames.has(o.package.name))
238-
cache.value = {
239-
...currentCache,
240-
objects: [...currentCache.objects, ...deduped],
241-
}
242-
}
243-
}
244-
} finally {
245-
isLoadingMore.value = false
246-
}
247-
}
248-
249-
// Reset cache when provider changes
250-
watch(
251-
() => searchProviderValue.value,
252-
() => {
253-
cache.value = null
254-
loadAllPromise = null
255-
},
256-
)
257-
258-
// Computed data that prefers cache
259-
const data = computed<OrgPackagesResponse | null>(() => {
260-
const org = toValue(orgName)
261-
if (cache.value && cache.value.org === org) {
262-
return {
263-
isStale: false,
264-
objects: cache.value.objects,
265-
total: cache.value.objects.length,
266-
totalPackages: cache.value.totalPackages,
267-
isTruncated: cache.value.objects.length < cache.value.allNames.length,
268-
time: new Date().toISOString(),
269-
}
270-
}
271-
return asyncData.data.value
272-
})
273-
274-
return {
275-
...asyncData,
276-
data,
277-
isLoadingMore,
278-
hasMore,
279-
loadAll,
280-
}
130+
return asyncData
281131
}

app/pages/org/[org].vue

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const orgName = computed(() => route.params.org.toLowerCase())
1515
const { isConnected } = useConnector()
1616
1717
// Fetch all packages in this org using the org packages API (lazy to not block navigation)
18-
const { data: results, status, error, isLoadingMore, hasMore, loadAll } = useOrgPackages(orgName)
18+
const { data: results, status, error } = useOrgPackages(orgName)
1919
2020
// Handle 404 errors reactively (since we're not awaiting)
2121
watch(
@@ -32,9 +32,11 @@ watch(
3232
{ immediate: true },
3333
)
3434
35-
const packages = computed(() => results.value?.objects ?? [])
35+
const allPackages = computed(() => results.value?.objects ?? [])
3636
const totalPackages = computed(() => results.value?.totalPackages ?? 0)
37-
const packageCount = computed(() => packages.value.length)
37+
38+
const { visibleItems: packages, hasMore, expand } = useVisibleItems(allPackages, 250)
39+
const packageCount = computed(() => allPackages.value.length)
3840
3941
// Preferences (persisted to localStorage)
4042
const { viewMode, paginationMode, pageSize, columns, toggleColumn, resetColumns } =
@@ -57,7 +59,7 @@ const {
5759
clearAllFilters,
5860
setSort,
5961
} = useStructuredFilters({
60-
packages,
62+
packages: allPackages,
6163
initialSort: (normalizeSearchParam(route.query.sort) as SortOption) ?? 'updated-desc',
6264
})
6365
@@ -69,15 +71,15 @@ const totalPages = computed(() => {
6971
return Math.ceil(sortedPackages.value.length / pageSize.value)
7072
})
7173
72-
// Reset to page 1 when filters change; load all results so client-side filtering works
74+
// Reset to page 1 when filters change; expand all so client-side filtering works
7375
watch([filters, sortOption], () => {
7476
currentPage.value = 1
7577
if (
7678
filters.value.text ||
7779
filters.value.keywords.length > 0 ||
7880
sortOption.value !== 'updated-desc'
7981
) {
80-
loadAll()
82+
expand()
8183
}
8284
})
8385
@@ -177,19 +179,13 @@ defineOgImageComponent('Default', {
177179
<template v-if="hasMore">
178180
{{
179181
$t('org.page.showing_packages', {
180-
loaded: $n(packageCount),
181-
total: $n(totalPackages),
182+
loaded: $n(packages.length),
183+
total: $n(packageCount),
182184
})
183185
}}
184186
</template>
185187
<template v-else>
186-
{{
187-
$t(
188-
'org.public_packages',
189-
{ count: $n(totalPackages || packageCount) },
190-
totalPackages || packageCount,
191-
)
192-
}}
188+
{{ $t('org.public_packages', { count: $n(packageCount) }, packageCount) }}
193189
</template>
194190
</p>
195191
</div>
@@ -260,7 +256,7 @@ defineOgImageComponent('Default', {
260256

261257
<!-- Loading state (only when no packages loaded yet) -->
262258
<LoadingSpinner
263-
v-if="status === 'pending' && packages.length === 0"
259+
v-if="status === 'pending' && allPackages.length === 0"
264260
:text="$t('common.loading_packages')"
265261
/>
266262

@@ -324,15 +320,14 @@ defineOgImageComponent('Default', {
324320
<PackageList
325321
:results="sortedPackages"
326322
:has-more="hasMore"
327-
:is-loading="isLoadingMore"
328323
:view-mode="viewMode"
329324
:columns="columns"
330325
:filters="filters"
331326
v-model:sort-option="sortOption"
332327
:pagination-mode="paginationMode"
333328
:page-size="pageSize"
334329
:current-page="currentPage"
335-
@load-more="loadAll"
330+
@load-more="expand"
336331
@click-keyword="toggleKeyword"
337332
/>
338333

0 commit comments

Comments
 (0)