Skip to content

feat: progressive loading for org packages#1953

Open
Adebesin-Cell wants to merge 18 commits intonpmx-dev:mainfrom
Adebesin-Cell:fix/org
Open

feat: progressive loading for org packages#1953
Adebesin-Cell wants to merge 18 commits intonpmx-dev:mainfrom
Adebesin-Cell:fix/org

Conversation

@Adebesin-Cell
Copy link
Copy Markdown
Contributor

@Adebesin-Cell Adebesin-Cell commented Mar 5, 2026

Summary

Addresses #1946, org pages hanging for large orgs like @types with 11,000+ packages.

The initial fix capped metadata fetching at 1000 packages, which stopped the hang, but it also meant everything past that limit was invisible.

What changed

  • Fast SSR: only the first 50 packages' metadata is fetched during server rendering (~250ms), down from all packages, which caused the hang
  • Incremental loading: scrolling or paginating loads the next 1000 packages per batch via Algolia, not everything at once
  • Full-dataset filters: filtering or sorting triggers loadAll() to fetch the complete dataset, so client-side search works across all packages
  • Extended useVisibleItems: added an onExpand callback with isExpanding state and partial-load support (return false to keep hasMore true for retries), as suggested by @ghostdevv
  • Retry-safe: remaining packages are tracked by name (not positional slicing), so partial Algolia failures don't permanently skip packages
  • Algolia batches run in parallel (Promise.allSettled), partial failures are handled gracefully
  • Header shows "Showing X of Y packages" while loading; pagination shows the real total
  • getPackagesByNameSlice extracted from Algolia composable for batched getObjects calls (max 1000 per request)

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Apr 14, 2026 8:50pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Apr 14, 2026 8:50pm
npmx-lunaria Ignored Ignored Apr 14, 2026 8:50pm

Request Review

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 5, 2026

Codecov Report

❌ Patch coverage is 0% with 18 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/composables/npm/useAlgoliaSearch.ts 0.00% 15 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@Adebesin-Cell Adebesin-Cell marked this pull request as ready for review March 17, 2026 02:14
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds batched Algolia lookups and a new slice API, implements progressive/org-aware package loading with staleness guards and in‑flight deduplication (exposing loadAll), updates visible-items to support async expand hooks, adapts the org page to progressive loading, and adds Vite+ skill references and documentation.

Changes

Cohort / File(s) Summary
Algolia search composable
app/composables/npm/useAlgoliaSearch.ts
Replaced single-request getPackagesByName with batched flow; added getPackagesByNameSlice(names); getPackagesByName now partitions names (1000) and aggregates concurrent slice results.
Org packages flow
app/composables/npm/useOrgPackages.ts
Added OrgPackagesResponse and progressive loading: initial slice fetch + exported loadAll(); added staleness guards, in‑flight promise reuse, batched concurrent Algolia requests, deduplication, and progressive updates to asyncData.
Visible items & UI integration
app/composables/useVisibleItems.ts, app/pages/org/[org].vue
useVisibleItems gains onExpand option, isExpanding state and async expand/toggle; org page wired to call loadAll on expand, updated spinner/empty-state, filter/sort now reset to trigger expansion when needed, and header/OG count prefer totalPackages.
I18n schema and strings
i18n/locales/en.json, i18n/schema.json
Added org.page.showing_packages translation and schema entry.
Vite+ agent tooling & docs
.claude/skills/vite-plus, .trae/skills/vite-plus, CLAUDE.md
Added skill reference files and CLAUDE.md documenting the Vite+ vp CLI commands, usage rules, CI snippet and agent checklist.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant OrgPage as "Org Page (Client UI)"
    participant useOrg as "useOrgPackages"
    participant Algolia
    participant Server as "Server (package-meta)"

    Client->>OrgPage: navigate to org page (orgName)
    OrgPage->>useOrg: useOrgPackages(orgName)
    Note over useOrg: capture org, fetch initial slice via getPackagesByNameSlice
    useOrg->>Algolia: getPackagesByNameSlice(batch 1)
    Algolia-->>useOrg: slice results
    useOrg-->>OrgPage: return initial asyncData (initialObjects, totalPackages)

    OrgPage->>useVisible: create visible list (onExpand -> useOrg.loadAll)

    alt user expands / onExpand triggered
        OrgPage->>useOrg: loadAll()
        Note over useOrg: partition remainingNames into ALGOLIA_BATCH_SIZE slices
        par concurrent slice fetches
            useOrg->>Algolia: getPackagesByNameSlice(batch N)
            Algolia-->>useOrg: results
        and
            useOrg->>Algolia: getPackagesByNameSlice(batch M)
            Algolia-->>useOrg: results
        end
        useOrg->>useOrg: dedupe & append loadedObjects
        useOrg->>useOrg: re-check org staleness
        alt some metadata missing
            useOrg->>Server: fetch package-meta fallback for remaining names
            Server-->>useOrg: fallback results
        end
        useOrg-->>OrgPage: update asyncData with appended objects
    end
Loading

Possibly Related PRs

Suggested Reviewers

  • danielroe
  • alexdln
  • trueberryless
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: progressive loading for org packages' clearly and concisely summarises the main change: introducing progressive/on-demand loading for organisation package listings.
Description check ✅ Passed The pull request description clearly relates to the changeset, detailing specific improvements for handling large org packages with progressive loading, batching, and incremental metadata fetching.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3cf16238-f272-4a67-8b0c-bc06ec8bf13a

📥 Commits

Reviewing files that changed from the base of the PR and between c5202d0 and 824cba3.

📒 Files selected for processing (3)
  • app/composables/npm/useAlgoliaSearch.ts
  • app/composables/npm/useOrgPackages.ts
  • app/pages/org/[org].vue

Instead of capping at 1000 packages and blocking on all metadata,
load the first 250 immediately and fetch the rest on demand.
Filtering/sorting triggers loadAll() so client-side search still
works on the full set.
@Adebesin-Cell Adebesin-Cell changed the title fix: org page fetching metadata at once feat: progressive loading for org packages Mar 17, 2026
@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 17, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
i18n/locales/en.json Source changed, localizations will be marked as outdated.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/composables/npm/useOrgPackages.ts (1)

249-256: Consider cancelling in-flight operations when the provider changes.

The watch resets the cache and promise lock but doesn't cancel any in-flight loadAll() operations. If a user toggles the search provider while loading is in progress, the old operation may complete and update the cache after the reset, causing inconsistent state.

The existing staleness guard on orgName (line 196) doesn't protect against provider changes.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: eeb7210c-a4af-4e08-ad83-232fa11b004d

📥 Commits

Reviewing files that changed from the base of the PR and between 824cba3 and 6e49401.

📒 Files selected for processing (5)
  • app/composables/npm/useAlgoliaSearch.ts
  • app/composables/npm/useOrgPackages.ts
  • app/pages/org/[org].vue
  • i18n/locales/en.json
  • i18n/schema.json

@serhalp serhalp added the needs review This PR is waiting for a review from a maintainer label Apr 6, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
app/composables/npm/useAlgoliaSearch.ts (1)

217-242: Consider adding a defensive guard for the documented 1000-item limit.

The JSDoc states "max 1000" but the function doesn't enforce this constraint. While callers (e.g., useOrgPackages.ts) correctly batch inputs to 1000 items before calling, a defensive check would make the contract self-enforcing and catch accidental misuse.

🛡️ Optional defensive check
 /** 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 []
+  if (names.length > 1000) {
+    throw new Error(`getPackagesByNameSlice: batch size ${names.length} exceeds Algolia limit of 1000`)
+  }
 
   const response = await $fetch<{ results: (AlgoliaHit | null)[] }>(

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fc7cae55-8a67-432f-a21d-e8abd23f16d9

📥 Commits

Reviewing files that changed from the base of the PR and between 3d53a70 and 8e5b116.

📒 Files selected for processing (3)
  • app/composables/npm/useAlgoliaSearch.ts
  • i18n/locales/en.json
  • i18n/schema.json
✅ Files skipped from review due to trivial changes (2)
  • i18n/locales/en.json
  • i18n/schema.json

Copy link
Copy Markdown
Contributor

@ghostdevv ghostdevv left a comment

Choose a reason for hiding this comment

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

Can we use the new composable James added in #2395?

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 509b1c68-959d-48d1-aafe-959b9c10edfb

📥 Commits

Reviewing files that changed from the base of the PR and between 8e5b116 and 8949880.

📒 Files selected for processing (4)
  • app/composables/npm/useOrgPackages.ts
  • app/pages/org/[org].vue
  • i18n/locales/en.json
  • i18n/schema.json
✅ Files skipped from review due to trivial changes (1)
  • i18n/locales/en.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • i18n/schema.json

Copy link
Copy Markdown
Contributor

@ghostdevv ghostdevv left a comment

Choose a reason for hiding this comment

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

I couldn't find the load more button? Also does the load more load every package, or just another 250?

"no_match": "No packages match \"{query}\"",
"not_found": "Organization not found",
"not_found_message": "The organization \"{'@'}{name}\" does not exist on npm"
"not_found_message": "The organization \"{'@'}{name}\" does not exist on npm",
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.

how come there is backticks and {'@'}?

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.

Good catch 👏, removed the quotes.

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.

what about the {'@'}?

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.

The @ is vue-i18n’s literal escape syntax. @ is a reserved character used for interpolation (see https://vue-i18n.intlify.dev/guide/essentials/syntax.html#special-characters), so writing @types directly would try to resolve a key called types instead of rendering the @ symbol.

It follows the same pattern used across all our locale files (e.g., atmosphere (@{handle}), Include @types in install, etc.).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
hasMore now combines both signals: items hidden by useVisibleItems AND
packages not yet fetched from the server. This fixes the missing
load-more button when the initial batch size equals the visibility limit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Extend useVisibleItems with onExpand callback + partial load support
- useOrgPackages: fetch first 50 on SSR, loadMore(1000) per page,
  loadAll() only when filters need the full dataset
- Track remaining by name (not position) for retry safety
- Pagination shows real total (11,397) while loading incrementally

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

jonchurch

This comment was marked as outdated.

@Adebesin-Cell
Copy link
Copy Markdown
Contributor Author

The PR does not currently do what it says in the description.

I am not able to make any pagination happen during testing on the preview, not on scroll nor on changing the filter.

Was this ever working for you in testing @Adebesin-Cell

checked with npmx-u41zkl5iu-npmx.vercel.app/org/sindresorhus

The PR does not currently do what it says in the description.

I am not able to make any pagination happen during testing on the preview, not on scroll nor on changing the filter.

Was this ever working for you in testing @Adebesin-Cell

checked with npmx-u41zkl5iu-npmx.vercel.app/org/sindresorhus

That’s weird. I did test locally, and loading more consistently triggers when you scroll to the end.

If you switch to table view, you can see it paginates correctly with the total package count showing (screenshot attached).

Screenshot 2026-04-14 at 09 37 30

I’ll look into why the infinite scroll load-more isn’t triggering on the preview deployment and fix it.

shallowRef state doesn't transfer from SSR to client, so loadMore()
had no package names to work with after hydration. Fix by including
allPackageNames in the async data response (transferred via Nuxt
payload) and reading from it instead of a separate shallowRef.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jonchurch
Copy link
Copy Markdown
Contributor

jonchurch commented Apr 14, 2026

It doesnt make much sense to split this view into pages this way while this view is a sorted list of unsorted data.

That is, we get a list of all packages, and then the view we present is always sorted. Yet we are not currently sorting the data in the query. So what good is showing me the top downloaded package from a random subset of 50 packages out of 100, 500, 1k+ or whatever?

I think the solution here is to query all data before rendering the page. That will still be significantly faster than the existing behavior, and is a much simpler fix.

My feedback to you here is to first solve the problem much more simply. It is an extremely low bar to improve the perf here, and the only reason the perf is impacted is because it is hitting the much slower fallback codepath due to the 400 that is hit on the fast path, see #2507

The actual needed fix is batching the network calls to get around the 1k package limit, not turning this view into a paged view.

Copy link
Copy Markdown
Contributor

@jonchurch jonchurch left a comment

Choose a reason for hiding this comment

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

The issue with not fetching the next "page" on scrolling to the bottom is fixed 👍

But if you test it now you'll see what I mean in #1953 (comment)

Lets say I want to see the top downloaded package of sindresorhus org. On first load I will just see whatever comes from the first 50 results. I'll think "hmm, i didnt expect that package to be first? oh well" because as a user I am seeing a sorted list but its unclear to me that its only a subset of 50. Sure it says its showing 50 of 1600, but surely its showing me the top 50 based on my sort choice? (it is not)

But imagine I'm a very Smart user, and I know exactly how the implementatino of this sorted view works! I will simply scroll to the bottom of the list of 50! Currently, it fetches the next 1k, re-sorts the list right? I scroll back up to see what the new top one is. Now I know what the top downloaded package of the first 1050 packages are. Now I want to see the list with all of them sorted! So I scroll down, through 1050 items, to trigger it to fetch the next 1k... I rinse and repeat for however many thousand I need to get through.

Image

My point is that the way this page is presented to the user as pre-sorted makes dynamic pagination here feel really bad as UX

@Adebesin-Cell
Copy link
Copy Markdown
Contributor Author

Adebesin-Cell commented Apr 14, 2026

It doesnt make much sense to split this view into pages this way while this view is a sorted list of unsorted data.

That is, we get a list of all packages, and then the view we present is always sorted. Yet we are not currently sorting the data in the query. So what good is showing me the top downloaded package from a random subset of 50 packages out of 100, 500, 1k+ or whatever?

I think the solution here is to query all data before rendering the page. That will still be significantly faster than the existing behavior, and is a much simpler fix.

My feedback to you here is to first solve the problem much more simply. It is an extremely low bar to improve the perf here, and the only reason the perf is impacted is because it is hitting the much slower fallback codepath due to the 400 that is hit on the fast path, see #2507

The actual needed fix is batching the network calls to get around the 1k package limit, not turning this view into a paged view.

I did approach the issue with batching at first. My commit fa6c5908 added Algolia batching to handle the 1k getObjects limit (the root cause of the 400 from #2507), though it was sequential and capped at 1000 packages. The 6e49401a removed the cap and made batches parallel, which is the actual performance fix. No more falling into the slow npm fallback path that was causing the hang.

The progressive loading layer (first 50, load more on scroll, load all on filter) came later based on #1953 (review), suggesting we adopt the useVisibleItems composable from #2395.

I agree that sorting on a partial dataset is misleading and that the batched Algolia path is fast enough to load everything upfront. I am happy to simplify back to "batch-fetch everything, let PackageList handle display." But since the progressive approach came from review feedback, it would be good to get @ghostdevv's take before I strip it out.

@43081j
Copy link
Copy Markdown
Contributor

43081j commented Apr 14, 2026

ok i've mostly skimmed the diff so let me know if i misunderstood anything.

i think @ghostdevv was suggesting useVisibleItems because this PR originally changed the UI to have paging, just without using the same composable the other pages use. In that situation, it would be the right thing to do - consolidate around useVisibleItems.

However, going back to the root problem, i'm not sure we need all of this.

As far as I understand, the root problem is that we send too many packages to Algolia in one request so we get an error back.

The solution to that is to batch the requests, ideally with a concurrency limit (i.e. so we don't just Promise.all the lot, we send a batch-of-batches at a time).

If we do that server-side, the UI receives the full batch as far as i understand and renders it in its entirety (no paging).

unrelated to the Algolia limit, it may be nicer UX to page those client-side one day. But that feels like something which needs a discussion first.

So my two suggested actions are:

  1. Create a PR which only batches the Algolia owner requests and leaves the UI as it is today
  2. Create an issue to discuss adding paging to the author page (clientside or serverside doesn't matter right now and would be part of the discussion)

@Adebesin-Cell
Copy link
Copy Markdown
Contributor Author

@43081j @jonchurch Thanks for the clear breakdown. That makes sense — Willow's suggestion was the right call given the PR had paging at the time, but the paging itself was solving a symptom rather than the root cause.

I'll simplify this PR down to just the Algolia batching fix (with concurrency limit) and leave the UI as-is. I've opened #2529 to discuss client-side paging for org pages separately as James suggested.

The root cause of npmx-dev#1946 and npmx-dev#2507 is that Algolia's getObjects API
has a 1000-item limit. Sending >1000 names returns a 400, which
triggers the slow npm fallback path (1 request per package).

Fix: batch getPackagesByName into chunks of 1000 with a concurrency
limit of 3. The org page and useOrgPackages stay unchanged — all
packages are fetched upfront and sorted client-side.

Client-side paging is tracked separately in npmx-dev#2529.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants