Skip to content

Commit 4c778dd

Browse files
committed
fix: use incremental searching
1 parent 88ddf89 commit 4c778dd

3 files changed

Lines changed: 203 additions & 92 deletions

File tree

app/composables/useNpmRegistry.ts

Lines changed: 148 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -263,40 +263,173 @@ const emptySearchResponse = {
263263
time: new Date().toISOString(),
264264
} satisfies NpmSearchResponse
265265

266-
/** @public */
266+
export interface NpmSearchOptions {
267+
/** Number of results to fetch */
268+
size?: number
269+
}
270+
271+
/** public */
267272
export function useNpmSearch(
268273
query: MaybeRefOrGetter<string>,
269-
options: MaybeRefOrGetter<{
270-
size?: number
271-
from?: number
272-
}> = {},
274+
options: MaybeRefOrGetter<NpmSearchOptions> = {},
273275
) {
274276
const cachedFetch = useCachedFetch()
277+
// Client-side cache
278+
const cache = shallowRef<{
279+
query: string
280+
objects: NpmSearchResult[]
281+
total: number
282+
} | null>(null)
283+
284+
const isLoadingMore = ref(false)
285+
286+
// Standard (non-incremental) search implementation
275287
let lastSearch: NpmSearchResponse | undefined = undefined
276288

277-
return useLazyAsyncData(
278-
() => `search:${toValue(query)}:${JSON.stringify(toValue(options))}`,
289+
const asyncData = useLazyAsyncData(
290+
`search:incremental:${toValue(query)}`,
279291
async () => {
280292
const q = toValue(query)
281293
if (!q.trim()) {
282-
return Promise.resolve(emptySearchResponse)
294+
return emptySearchResponse
283295
}
284296

297+
const opts = toValue(options)
298+
299+
// This only runs for initial load or query changes
300+
// Reset cache for new query
301+
cache.value = null
302+
285303
const params = new URLSearchParams()
286304
params.set('text', q)
287-
const opts = toValue(options)
288-
if (opts.size) params.set('size', String(opts.size))
289-
if (opts.from) params.set('from', String(opts.from))
305+
// Use requested size for initial fetch
306+
params.set('size', String(opts.size ?? 25))
290307

291-
// Note: Search results have a short TTL (1 minute) since they change frequently
292-
return (lastSearch = await cachedFetch<NpmSearchResponse>(
308+
const response = await cachedFetch<NpmSearchResponse>(
293309
`${NPM_REGISTRY}/-/v1/search?${params.toString()}`,
294310
{},
295-
60, // 1 minute TTL for search results
296-
))
311+
60,
312+
)
313+
314+
cache.value = {
315+
query: q,
316+
objects: response.objects,
317+
total: response.total,
318+
}
319+
320+
return response
297321
},
298322
{ default: () => lastSearch || emptySearchResponse },
299323
)
324+
325+
// Fetch more results incrementally (only used in incremental mode)
326+
async function fetchMore(targetSize: number): Promise<void> {
327+
const q = toValue(query).trim()
328+
if (!q) {
329+
cache.value = null
330+
return
331+
}
332+
333+
// If query changed, reset cache (shouldn't happen, but safety check)
334+
if (cache.value && cache.value.query !== q) {
335+
cache.value = null
336+
await asyncData.refresh()
337+
return
338+
}
339+
340+
const currentCount = cache.value?.objects.length ?? 0
341+
const total = cache.value?.total ?? Infinity
342+
343+
// Already have enough or no more to fetch
344+
if (currentCount >= targetSize || currentCount >= total) {
345+
return
346+
}
347+
348+
isLoadingMore.value = true
349+
350+
try {
351+
// Fetch from where we left off - calculate size needed
352+
const from = currentCount
353+
const size = Math.min(targetSize - currentCount, total - currentCount)
354+
355+
const params = new URLSearchParams()
356+
params.set('text', q)
357+
params.set('size', String(size))
358+
params.set('from', String(from))
359+
360+
const response = await cachedFetch<NpmSearchResponse>(
361+
`${NPM_REGISTRY}/-/v1/search?${params.toString()}`,
362+
{},
363+
60,
364+
)
365+
366+
// Update cache
367+
if (cache.value && cache.value.query === q) {
368+
cache.value = {
369+
query: q,
370+
objects: [...cache.value.objects, ...response.objects],
371+
total: response.total,
372+
}
373+
} else {
374+
cache.value = {
375+
query: q,
376+
objects: response.objects,
377+
total: response.total,
378+
}
379+
}
380+
381+
// If we still need more, fetch again recursively
382+
if (
383+
cache.value.objects.length < targetSize &&
384+
cache.value.objects.length < cache.value.total
385+
) {
386+
await fetchMore(targetSize)
387+
}
388+
} finally {
389+
isLoadingMore.value = false
390+
}
391+
}
392+
393+
// Watch for size increases in incremental mode
394+
watch(
395+
() => toValue(options).size,
396+
async (newSize, oldSize) => {
397+
if (!newSize) return
398+
if (oldSize && newSize > oldSize && toValue(query).trim()) {
399+
await fetchMore(newSize)
400+
}
401+
},
402+
)
403+
404+
// Computed data that uses cache in incremental mode
405+
const data = computed<NpmSearchResponse | null>(() => {
406+
if (cache.value) {
407+
return {
408+
objects: cache.value.objects,
409+
total: cache.value.total,
410+
time: new Date().toISOString(),
411+
}
412+
}
413+
return asyncData.data.value
414+
})
415+
416+
// Whether there are more results available on the server (incremental mode only)
417+
const hasMore = computed(() => {
418+
if (!cache.value) return true
419+
return cache.value.objects.length < cache.value.total
420+
})
421+
422+
return {
423+
...asyncData,
424+
/** Reactive search results (uses cache in incremental mode) */
425+
data,
426+
/** Whether currently loading more results (incremental mode only) */
427+
isLoadingMore,
428+
/** Whether there are more results available (incremental mode only) */
429+
hasMore,
430+
/** Manually fetch more results up to target size (incremental mode only) */
431+
fetchMore,
432+
}
300433
}
301434

302435
/**

app/pages/search.vue

Lines changed: 39 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,15 @@ onMounted(() => {
7171
}, 300)
7272
})
7373
74-
// Infinite scroll state
74+
// Infinite scroll / pagination state
7575
const pageSize = 25
76-
const loadedPages = ref(2)
77-
const isLoadingMore = ref(false)
78-
79-
// Pagination state for table view
8076
const currentPage = ref(1)
81-
watch(currentPage, page => {
82-
loadedPages.value = Math.max(loadedPages.value, page + 1)
77+
78+
// Calculate how many results we need based on current page and preferred page size
79+
const requestedSize = computed(() => {
80+
const numericPrefSize = preferredPageSize.value === 'all' ? 250 : preferredPageSize.value
81+
// Always fetch at least enough for the current page
82+
return Math.max(pageSize, currentPage.value * numericPrefSize)
8383
})
8484
8585
// Get initial page from URL (for scroll restoration on reload)
@@ -88,53 +88,44 @@ const initialPage = computed(() => {
8888
return Number.isNaN(p) ? 1 : Math.max(1, p)
8989
})
9090
91-
// Initialize loaded pages from URL on mount
91+
// Initialize current page from URL on mount
9292
onMounted(() => {
9393
if (initialPage.value > 1) {
94-
// Load enough pages to show the initial page
95-
loadedPages.value = initialPage.value
94+
currentPage.value = initialPage.value
9695
}
9796
})
9897
99-
// fetch all pages up to current
100-
const { data: results, status } = useNpmSearch(query, () => ({
101-
size: pageSize * loadedPages.value,
102-
from: 0,
98+
// Use incremental search with client-side caching
99+
const {
100+
data: results,
101+
status,
102+
isLoadingMore,
103+
hasMore,
104+
fetchMore,
105+
} = useNpmSearch(query, () => ({
106+
size: requestedSize.value,
107+
incremental: true,
103108
}))
104109
105-
// Keep track of previous results to show while loading
106-
// Use useState so the value persists from SSR to client hydration
110+
// Track previous query for UI continuity
107111
const previousQuery = useState('search-previous-query', () => query.value)
108-
const cachedResults = ref(results.value)
109-
110-
// Update cached results smartly
111-
watch([results, query], ([newResults, newQuery]) => {
112-
if (newResults) {
113-
cachedResults.value = newResults
114-
previousQuery.value = newQuery
115-
isLoadingMore.value = false
116-
}
117-
})
118112
119-
// Determine if we should show previous results while loading
120-
// (when new query is a continuation of the old one)
121-
const isQueryContinuation = computed(() => {
122-
const current = query.value.toLowerCase()
123-
const previous = previousQuery.value.toLowerCase()
124-
return previous && current.startsWith(previous)
125-
})
113+
// Update previous query when results change
114+
watch(
115+
() => results.value,
116+
newResults => {
117+
if (newResults && newResults.objects.length > 0) {
118+
previousQuery.value = query.value
119+
}
120+
},
121+
)
126122
127123
const resultsMatchQuery = computed(() => {
128124
return previousQuery.value === query.value
129125
})
130126
131-
// Show cached results while loading if it's a continuation query
132-
const rawVisibleResults = computed(() => {
133-
if (status.value === 'pending' && isQueryContinuation.value && cachedResults.value) {
134-
return cachedResults.value
135-
}
136-
return results.value
137-
})
127+
// Results to display (directly from incremental search)
128+
const rawVisibleResults = computed(() => results.value)
138129
139130
// Settings for platform package filtering
140131
const { settings } = useSettings()
@@ -229,37 +220,30 @@ function handleSortChange(option: SortOption) {
229220
const showSearching = computed(() => {
230221
// Don't show during initial page load (view transition)
231222
if (!hasInteracted.value) return false
232-
// Don't show if we're displaying cached results
233-
if (status.value === 'pending' && isQueryContinuation.value && cachedResults.value) return false
234-
// Show if pending on first page
235-
return status.value === 'pending' && loadedPages.value === 1
223+
// Show if pending and no results yet
224+
return status.value === 'pending' && displayResults.value.length === 0
236225
})
237226
238227
const totalPages = computed(() => {
239228
if (!visibleResults.value) return 0
240229
return Math.ceil(visibleResults.value.total / pageSize)
241230
})
242231
243-
const hasMore = computed(() => {
244-
return loadedPages.value < totalPages.value
245-
})
246-
247232
// Load more when triggered by infinite scroll
248-
function loadMore() {
233+
async function loadMore() {
249234
if (isLoadingMore.value || !hasMore.value) return
250-
251-
isLoadingMore.value = true
252-
loadedPages.value++
235+
// Increase requested size to trigger fetch
236+
currentPage.value++
237+
await fetchMore(requestedSize.value)
253238
}
254239
255240
// Update URL when page changes from scrolling
256241
function handlePageChange(page: number) {
257242
updateUrlPage(page)
258243
}
259244
260-
// Reset pages when query changes
245+
// Reset page when query changes
261246
watch(query, () => {
262-
loadedPages.value = 1
263247
currentPage.value = 1
264248
hasInteracted.value = true
265249
})
@@ -967,7 +951,7 @@ defineOgImageComponent('Default', {
967951
heading-level="h2"
968952
show-publisher
969953
:has-more="hasMore"
970-
:is-loading="isLoadingMore || (status === 'pending' && loadedPages > 1)"
954+
:is-loading="isLoadingMore"
971955
:page-size="preferredPageSize"
972956
:initial-page="initialPage"
973957
:view-mode="viewMode"

0 commit comments

Comments
 (0)