Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
fa6c590
fix: org page fetching metadata at once
Adebesin-Cell Mar 5, 2026
824cba3
Merge branch 'main' into fix/org
Adebesin-Cell Mar 17, 2026
6e49401
feat: progressive loading for org packages
Adebesin-Cell Mar 17, 2026
3d53a70
Merge branch 'main' into fix/org
Adebesin-Cell Mar 18, 2026
8e5b116
Merge branch 'main' into fix/org
serhalp Apr 6, 2026
5686329
Merge branch 'main' into fix/org
ghostdevv Apr 9, 2026
89edc2b
refactor: use extended useVisibleItems for progressive org loading
Adebesin-Cell Apr 9, 2026
44cc1c2
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 13, 2026
611ecc6
Merge branch 'main' into fix/org
Adebesin-Cell Apr 13, 2026
7082536
fix: track remaining packages by name and support partial expand
Adebesin-Cell Apr 13, 2026
21ecd99
chore: remove accidentally committed config files
Adebesin-Cell Apr 13, 2026
c99df23
fix: show load-more when server has unfetched packages
Adebesin-Cell Apr 13, 2026
686ad04
refactor: progressive org loading with incremental batches
Adebesin-Cell Apr 13, 2026
f91feb3
fix: remove unnecessary quotes around org name in i18n message
Adebesin-Cell Apr 13, 2026
7e0f14c
Merge branch 'main' into fix/org
Adebesin-Cell Apr 13, 2026
68fe83b
Merge branch 'main' into fix/org
Adebesin-Cell Apr 14, 2026
91aaf01
fix: persist allPackageNames in Nuxt payload for client hydration
Adebesin-Cell Apr 14, 2026
2f85269
fix: batch Algolia getObjects requests for large orgs
Adebesin-Cell Apr 14, 2026
e22bd3c
fix: avoid spread into push for large batch results
Adebesin-Cell Apr 15, 2026
16dc489
Merge branch 'main' into fix/org
Adebesin-Cell Apr 15, 2026
b72db1f
Merge branch 'main' into fix/org
Adebesin-Cell Apr 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions app/composables/npm/useAlgoliaSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,9 @@ export function useAlgoliaSearch() {
}
}

/** Fetch metadata for specific packages by exact name using Algolia's getObjects API. */
async function getPackagesByName(packageNames: string[]): Promise<NpmSearchResponse> {
if (packageNames.length === 0) {
return { isStale: false, objects: [], total: 0, time: new Date().toISOString() }
}
/** Fetch metadata for a single batch of packages (max 1000) by exact name. */
async function getPackagesByNameSlice(names: string[]): Promise<NpmSearchResult[]> {
if (names.length === 0) return []

const response = await $fetch<{ results: (AlgoliaHit | null)[] }>(
`https://${algolia.appId}-dsn.algolia.net/1/indexes/*/objects`,
Expand All @@ -229,7 +227,7 @@ export function useAlgoliaSearch() {
'x-algolia-application-id': algolia.appId,
},
body: {
requests: packageNames.map(name => ({
requests: names.map(name => ({
indexName,
objectID: name,
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
Expand All @@ -238,11 +236,31 @@ export function useAlgoliaSearch() {
},
)

const hits = response.results.filter((r): r is AlgoliaHit => r !== null && 'name' in r)
return response.results
.filter((r): r is AlgoliaHit => r !== null && 'name' in r)
.map(hitToSearchResult)
}

/** Fetch metadata for specific packages by exact name using Algolia's getObjects API. */
async function getPackagesByName(packageNames: string[]): Promise<NpmSearchResponse> {
if (packageNames.length === 0) {
return { isStale: false, objects: [], total: 0, time: new Date().toISOString() }
}

// Algolia getObjects has a limit of 1000 objects per request, so batch if needed
const BATCH_SIZE = 1000
const batches: string[][] = []
for (let i = 0; i < packageNames.length; i += BATCH_SIZE) {
batches.push(packageNames.slice(i, i + BATCH_SIZE))
}

const results = await Promise.all(batches.map(batch => getPackagesByNameSlice(batch)))
const allObjects = results.flat()

return {
isStale: false,
objects: hits.map(hitToSearchResult),
total: hits.length,
objects: allObjects,
total: allObjects.length,
time: new Date().toISOString(),
}
}
Expand Down Expand Up @@ -349,5 +367,6 @@ export function useAlgoliaSearch() {
searchWithSuggestions,
searchByOwner,
getPackagesByName,
getPackagesByNameSlice,
}
}
194 changes: 167 additions & 27 deletions app/composables/npm/useOrgPackages.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,34 @@
import type { NpmSearchResponse, NpmSearchResult, PackageMetaResponse } from '#shared/types'
import { emptySearchResponse, metaToSearchResult } from './search-utils'
import { mapWithConcurrency } from '#shared/utils/async'

/** Number of packages to fetch metadata for in the initial load */
const INITIAL_BATCH_SIZE = 50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why only 50? This is a very fast query. Are you limiting the initial data fetch to get a neat page of 50 packages displayed in the UI? That's convenient for the UI but inefficient for the network call budget.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran some benchmarks against the Algolia getObjects endpoint for @types (11,397 packages):

Initial batch size Algolia response time (avg of 3 runs)
50 ~680ms
100 ~1,030ms
150 ~1,120ms
200 ~1,065ms
250 ~1,170ms
500 ~1,470ms
1,000 ~2,000ms

This is a single Algolia getObjects POST during SSR. It blocks the HTML response. The registry fetch for the package names list is ~20ms (cached), so Algolia is the bottleneck.

50 to 100 adds ~350ms but doubles the initial content. 100 to 250 adds ~140ms more, but the response is already over a second. The default page size is 25, so 100 gives users 4 pages of content on first paint without needing a second fetch.

I can bump it to 100. It is a good balance between content density and SSR speed. The remaining packages load incrementally (1,000 per batch) as the user scrolls or paginates, or all at once when they filter or sort.


/** Max names per Algolia getObjects request */
const ALGOLIA_BATCH_SIZE = 1000

export interface OrgPackagesResponse extends NpmSearchResponse {
/** Total number of packages in the org (may exceed objects.length before loadAll) */
totalPackages: number
/** All package names in the org (used by loadMore to know what to fetch next) */
allPackageNames: string[]
}

function emptyOrgResponse(): OrgPackagesResponse {
return {
...emptySearchResponse(),
totalPackages: 0,
allPackageNames: [],
}
}

/**
* Fetch all packages for an npm organization.
* Fetch packages for an npm organization with progressive loading.
*
* 1. Gets the authoritative package list from the npm registry (single request)
* 2. Fetches metadata from Algolia by exact name (single request)
* 3. Falls back to lightweight server-side package-meta lookups
* 2. Fetches metadata for the first batch immediately (fast SSR)
* 3. Remaining packages are loaded on-demand via `loadAll()`
*/
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
const route = useRoute()
Expand All @@ -13,17 +38,22 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
if (p === 'npm' || searchProvider.value === 'npm') return 'npm'
return 'algolia'
})
const { getPackagesByName } = useAlgoliaSearch()
const { getPackagesByNameSlice } = useAlgoliaSearch()

const loadedObjects = shallowRef<NpmSearchResult[]>([])

// Promise lock — scoped inside the composable to avoid cross-instance sharing
let loadAllPromise: Promise<void> | null = null

const asyncData = useLazyAsyncData(
() => `org-packages:${searchProviderValue.value}:${toValue(orgName)}`,
async ({ ssrContext }, { signal }) => {
const org = toValue(orgName)
if (!org) {
return emptySearchResponse()
return emptyOrgResponse()
}

// Get the authoritative package list from the npm registry (single request)
// Get the authoritative package list from the npm registry
let packageNames: string[]
try {
const { packages } = await $fetch<{ packages: string[]; count: number }>(
Expand All @@ -32,7 +62,6 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
)
packageNames = packages
} catch (err) {
// Check if this is a 404 (org not found)
if (err && typeof err === 'object' && 'statusCode' in err && err.statusCode === 404) {
const error = createError({
statusCode: 404,
Expand All @@ -44,34 +73,131 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
}
throw error
}
// For other errors (network, etc.), return empty array to be safe
packageNames = []
}

if (packageNames.length === 0) {
return emptySearchResponse()
loadedObjects.value = []
return emptyOrgResponse()
}

// Fetch metadata + downloads from Algolia (single request via getObjects)
const initialNames = packageNames.slice(0, INITIAL_BATCH_SIZE)

// Fetch metadata for first batch
let initialObjects: NpmSearchResult[] = []

if (searchProviderValue.value === 'algolia') {
try {
const response = await getPackagesByName(packageNames)
if (response.objects.length > 0) {
return response
}
initialObjects = await getPackagesByNameSlice(initialNames)
} catch {
// Fall through to npm registry path
// Fall through to npm fallback
}
}

// npm fallback: fetch lightweight metadata via server proxy
// Staleness guard
if (toValue(orgName) !== org) return emptyOrgResponse()

// npm fallback for initial batch
if (initialObjects.length === 0) {
const metaResults = await mapWithConcurrency(
initialNames,
async name => {
try {
return await $fetch<PackageMetaResponse>(
`/api/registry/package-meta/${encodePackageName(name)}`,
{ signal },
)
} catch {
return null
}
},
10,
)

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

initialObjects = metaResults
.filter((meta): meta is PackageMetaResponse => meta !== null)
.map(metaToSearchResult)
}

loadedObjects.value = initialObjects

return {
isStale: false,
objects: initialObjects,
total: initialObjects.length,
totalPackages: packageNames.length,
allPackageNames: packageNames,
time: new Date().toISOString(),
} satisfies OrgPackagesResponse
},
{ default: emptyOrgResponse },
)

/** Read allPackageNames from async data (survives SSR→client hydration via Nuxt payload). */
function allPackageNames(): string[] {
return asyncData.data.value?.allPackageNames ?? []
}

/** Load the next batch of packages (default: 1 Algolia batch of 1000). */
async function loadMore(count: number = ALGOLIA_BATCH_SIZE): Promise<void> {
const loadedSet = new Set(loadedObjects.value.map(o => o.package.name))
if (loadedSet.size >= allPackageNames().length) return

// Reuse in-flight promise to prevent duplicate fetches
if (loadAllPromise) {
await loadAllPromise
return
}

loadAllPromise = _doLoadMore(count)
try {
await loadAllPromise
} finally {
loadAllPromise = null
}
}

/** Load ALL remaining packages (used when filters need the full dataset). */
async function loadAll(): Promise<void> {
const remaining = allPackageNames().length - loadedObjects.value.length
if (remaining <= 0) return
await loadMore(remaining)
}

async function _doLoadMore(count: number): Promise<void> {
const names = allPackageNames()
const current = loadedObjects.value
const loadedSet = new Set(current.map(o => o.package.name))
const remainingNames = names.filter(n => !loadedSet.has(n)).slice(0, count)
if (remainingNames.length === 0) return

const org = toValue(orgName)
let newObjects: NpmSearchResult[] = []

if (searchProviderValue.value === 'algolia') {
const batches: string[][] = []
for (let i = 0; i < remainingNames.length; i += ALGOLIA_BATCH_SIZE) {
batches.push(remainingNames.slice(i, i + ALGOLIA_BATCH_SIZE))
}

const results = await Promise.allSettled(batches.map(batch => getPackagesByNameSlice(batch)))

if (toValue(orgName) !== org) return

for (const result of results) {
if (result.status === 'fulfilled') {
newObjects.push(...result.value)
}
}
} else {
const metaResults = await mapWithConcurrency(
packageNames,
remainingNames,
async name => {
try {
return await $fetch<PackageMetaResponse>(
`/api/registry/package-meta/${encodePackageName(name)}`,
{ signal },
)
} catch {
return null
Expand All @@ -80,19 +206,33 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
10,
)

const results: NpmSearchResult[] = metaResults
if (toValue(orgName) !== org) return

newObjects = metaResults
.filter((meta): meta is PackageMetaResponse => meta !== null)
.map(metaToSearchResult)
}

return {
if (newObjects.length > 0) {
const deduped = newObjects.filter(o => !loadedSet.has(o.package.name))
const all = [...current, ...deduped]
loadedObjects.value = all

// Update asyncData so the page sees the new objects
asyncData.data.value = {
isStale: false,
objects: results,
total: results.length,
objects: all,
total: all.length,
totalPackages: names.length,
allPackageNames: names,
time: new Date().toISOString(),
} satisfies NpmSearchResponse
},
{ default: emptySearchResponse },
)
}
}
}

return asyncData
return {
...asyncData,
loadMore,
loadAll,
}
}
42 changes: 36 additions & 6 deletions app/composables/useVisibleItems.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import { computed, shallowRef, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'

export function useVisibleItems<T>(items: MaybeRefOrGetter<T[]>, limit: number) {
export interface UseVisibleItemsOptions {
/**
* Called when expanding. Useful for loading remaining data on demand.
* If it returns a promise, `isExpanding` will be `true` until it resolves.
* Return `false` to signal a partial load — `showAll` stays false so
* `hasMore` remains true and the user can retry.
*/
onExpand?: () => void | boolean | Promise<void | boolean>
}

export function useVisibleItems<T>(
items: MaybeRefOrGetter<T[]>,
limit: number,
options?: UseVisibleItemsOptions,
) {
const showAll = shallowRef(false)
const isExpanding = shallowRef(false)

const visibleItems = computed(() => {
const list = toValue(items)
Expand All @@ -15,15 +30,30 @@ export function useVisibleItems<T>(items: MaybeRefOrGetter<T[]>, limit: number)

const hasMore = computed(() => !showAll.value && toValue(items).length > limit)

const expand = () => {
showAll.value = true
const expand = async () => {
if (showAll.value) return
let fullyLoaded = true
if (options?.onExpand) {
isExpanding.value = true
try {
const result = await options.onExpand()
if (result === false) fullyLoaded = false
} finally {
isExpanding.value = false
}
}
if (fullyLoaded) showAll.value = true
}
const collapse = () => {
showAll.value = false
}
const toggle = () => {
showAll.value = !showAll.value
const toggle = async () => {
if (showAll.value) {
collapse()
} else {
await expand()
}
}

return { visibleItems, hiddenCount, hasMore, showAll, expand, collapse, toggle }
return { visibleItems, hiddenCount, hasMore, isExpanding, showAll, expand, collapse, toggle }
}
Loading
Loading