Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
47 changes: 28 additions & 19 deletions app/composables/npm/useAlgoliaSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,29 +228,38 @@ export function useAlgoliaSearch() {
return { isStale: false, objects: [], total: 0, time: new Date().toISOString() }
}

const response = await $fetch<{ results: (AlgoliaHit | null)[] }>(
`https://${algolia.appId}-dsn.algolia.net/1/indexes/*/objects`,
{
method: 'POST',
headers: {
'x-algolia-api-key': algolia.apiKey,
'x-algolia-application-id': algolia.appId,
},
body: {
requests: packageNames.map(name => ({
indexName,
objectID: name,
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
})),
// Algolia getObjects has a limit of 1000 objects per request, so batch if needed
const BATCH_SIZE = 1000
const allHits: AlgoliaHit[] = []

for (let i = 0; i < packageNames.length; i += BATCH_SIZE) {
const batch = packageNames.slice(i, i + BATCH_SIZE)
const response = await $fetch<{ results: (AlgoliaHit | null)[] }>(
`https://${algolia.appId}-dsn.algolia.net/1/indexes/*/objects`,
{
method: 'POST',
headers: {
'x-algolia-api-key': algolia.apiKey,
'x-algolia-application-id': algolia.appId,
},
body: {
requests: batch.map(name => ({
indexName,
objectID: name,
attributesToRetrieve: ATTRIBUTES_TO_RETRIEVE,
})),
},
},
},
)
)

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

const hits = response.results.filter((r): r is AlgoliaHit => r !== null && 'name' in r)
return {
isStale: false,
objects: hits.map(hitToSearchResult),
total: hits.length,
objects: allHits.map(hitToSearchResult),
total: allHits.length,
time: new Date().toISOString(),
}
}
Expand Down
50 changes: 41 additions & 9 deletions app/composables/npm/useOrgPackages.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
/**
* Fetch all packages for an npm organization.
* Maximum number of packages to fetch metadata for.
* Large orgs (e.g. @types with 8000+ packages) would otherwise trigger
* thousands of network requests, causing severe performance degradation.
* Algolia batches in chunks of 1000; npm fallback fetches individually.
*/
const MAX_ORG_PACKAGES = 1000

export interface OrgPackagesResponse extends NpmSearchResponse {
/** Total number of packages in the org (may exceed objects.length if capped) */
totalPackages: number
}

function emptyOrgResponse(): OrgPackagesResponse {
return {
...emptySearchResponse(),
totalPackages: 0,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/**
* Fetch packages for an npm organization.
*
* 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. Caps to MAX_ORG_PACKAGES to prevent excessive network requests
* 3. Fetches metadata from Algolia by exact name (batched in chunks of 1000)
* 4. Falls back to lightweight server-side package-meta lookups
*/
export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
const route = useRoute()
Expand All @@ -20,7 +41,7 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
async ({ ssrContext }, { signal }) => {
const org = toValue(orgName)
if (!org) {
return emptySearchResponse()
return emptyOrgResponse()
}

// Get the authoritative package list from the npm registry (single request)
Expand Down Expand Up @@ -49,15 +70,25 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
}

if (packageNames.length === 0) {
return emptySearchResponse()
return emptyOrgResponse()
}

const totalPackages = packageNames.length

// Cap the number of packages to fetch metadata for
if (packageNames.length > MAX_ORG_PACKAGES) {
packageNames = packageNames.slice(0, MAX_ORG_PACKAGES)
}

// Fetch metadata + downloads from Algolia (single request via getObjects)
// Fetch metadata + downloads from Algolia (batched in chunks of 1000)
if (searchProviderValue.value === 'algolia') {
try {
const response = await getPackagesByName(packageNames)
if (response.objects.length > 0) {
return response
return {
...response,
totalPackages,
} satisfies OrgPackagesResponse
}
} catch {
// Fall through to npm registry path
Expand Down Expand Up @@ -88,10 +119,11 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
isStale: false,
objects: results,
total: results.length,
totalPackages,
time: new Date().toISOString(),
} satisfies NpmSearchResponse
} satisfies OrgPackagesResponse
},
{ default: emptySearchResponse },
{ default: emptyOrgResponse },
)

return asyncData
Expand Down
14 changes: 12 additions & 2 deletions app/pages/org/[org].vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ watch(
)

const packages = computed(() => results.value?.objects ?? [])
const totalPackages = computed(() => results.value?.totalPackages ?? 0)
const packageCount = computed(() => packages.value.length)

// Preferences (persisted to localStorage)
Expand Down Expand Up @@ -141,7 +142,10 @@ useSeoMeta({

defineOgImageComponent('Default', {
title: () => `@${orgName.value}`,
description: () => (packageCount.value ? `${packageCount.value} packages` : 'npm organization'),
description: () =>
totalPackages.value || packageCount.value
? `${totalPackages.value || packageCount.value} packages`
: 'npm organization',
primaryColor: '#60a5fa',
})
</script>
Expand All @@ -163,7 +167,13 @@ defineOgImageComponent('Default', {
<div>
<h1 class="font-mono text-2xl sm:text-3xl font-medium">@{{ orgName }}</h1>
<p v-if="status === 'success'" class="text-fg-muted text-sm mt-1">
{{ $t('org.public_packages', { count: $n(packageCount) }, packageCount) }}
{{
$t(
'org.public_packages',
{ count: $n(totalPackages || packageCount) },
totalPackages || packageCount,
)
}}
</p>
</div>

Expand Down
Loading