fix(admin): use infinite scroll for media library to support large libraries#996
Conversation
…braries Mirror the content list fix from emdash-cms#135 for the media library admin page. Previously `MediaPage` called `fetchMediaList()` with no arguments, so the backend defaulted to limit=50 and the library only ever showed the first 50 items. The cursor pagination in the media repository was already implemented but the admin never used it. This wires `MediaPage` to `useInfiniteQuery` with `limit: 100` per page and adds `hasMore` / `onLoadMore` props to `MediaLibrary` so a "Load More" button appears when more items are available (local provider only; external providers continue to manage their own pagination internally).
🦋 Changeset detectedLatest commit: ad15161 The changes in this PR will be included in the next version bump. This PR includes changesets to release 13 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Pull request overview
This PR updates the admin media library route and UI to support cursor-based pagination so large media libraries (>50 items) can be fully browsed, mirroring the earlier content list pagination work.
Changes:
- Switch
MediaPagefromuseQuerytouseInfiniteQueryand flatten paged results into a singleitemsarray. - Extend
MediaLibrarywithhasMore/onLoadMoreprops and render a local-library “Load More” button. - Add component tests covering the “Load More” button rendering and click behavior, and include a changeset.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/admin/src/router.tsx | Wires the media route to useInfiniteQuery, flattens pages, and passes pagination props into MediaLibrary. |
| packages/admin/src/components/MediaLibrary.tsx | Adds pagination props and renders a local-only “Load More” control. |
| packages/admin/tests/components/MediaLibrary.test.tsx | Adds tests for the “Load More” button presence/absence and callback invocation. |
| .changeset/fix-media-library-infinite-scroll.md | Patch changeset describing the media library pagination fix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| items={data?.items || []} | ||
| isLoading={isLoading} | ||
| items={items} | ||
| isLoading={isLoading || isFetchingNextPage} |
There was a problem hiding this comment.
Good catch, thanks. Fixed in ad15161.
I considered two ways to address this:
A. Split isLoading into two props on MediaLibrary (e.g. isLoading for the initial fetch + a new isLoadingMore for the button), as your comment suggests.
B. Keep the single isLoading prop but gate the full-area loader on items.length === 0 inside MediaLibrary, so the spinner only renders during the initial fetch and the grid stays in place while a Load-More fetch is in flight.
I went with B to match the existing precedent set by ContentList from #135 — same root cause, same fix recipe, no new prop to teach callers about. The "Load More" button keeps using isLoading for its disabled/label state, which is correct because during pagination isLoading || isFetchingNextPage is exactly what should make the button show "Loading..." and become unclickable. Only the grid shouldn't disappear, and that's what the items.length === 0 gate fixes.
Added a regression test (MediaLibrary.test.tsx "keeps already-loaded items visible while fetching the next page") that fails on the old code path and passes on the new one. Same treatment applied to MediaPickerModal since it had the identical issue.
| {/* Load more (local library only — providers handle pagination internally) */} | ||
| {activeProvider === "local" && hasMore && onLoadMore && ( | ||
| <div className="flex justify-center"> | ||
| <Button variant="outline" onClick={onLoadMore} disabled={isLoading}> | ||
| {isLoading ? t`Loading...` : t`Load More`} | ||
| </Button> |
There was a problem hiding this comment.
Addressed in ad15161 — see the longer reply on the sibling comment in router.tsx for the full rationale.
tl;dr: instead of adding a new isFetchingMore prop, I gated the full-area loader inside this component on items.length === 0 (matching the ContentList pattern from #135). The "Load More" button keeps using isLoading for its disabled/label state — that part is intentional, because during pagination it should show "Loading..." and be unclickable. Only the grid shouldn't disappear, which is what the gate fixes. Added a regression test in MediaLibrary.test.tsx ("keeps already-loaded items visible while fetching the next page").
The same 50-item ceiling existed in MediaPickerModal (used by the rich
text editor and image fields when embedding media into content): the
modal hardcoded `limit: 50` on a single useQuery call with no Load More
UI, so users could only ever pick from the most recent 50 items.
- Switch the local-library query to useInfiniteQuery with limit: 100
- Update the dimensions mutation's setQueryData to walk every infinite
page rather than the legacy `{ items, nextCursor }` shape
- Render a Load More button under the picker grid (local provider only;
external providers continue to manage pagination internally)
- Add 3 tests covering the Load More button visibility and click flow
Address Copilot review feedback (PR emdash-cms#996 review): the previous patch passed `isLoading || isFetchingNextPage` through to the loading-state branch, so clicking "Load More" temporarily blanked out the entire grid into a centered spinner before the next page arrived. Follow the same fix recipe used by ContentList in emdash-cms#135: gate the full-area loader on items.length === 0 so the spinner only renders during the *initial* fetch. While paginating, already-rendered items stay in place — keeping the user's scroll position and selection. Applied symmetrically to MediaLibrary and MediaPickerModal. Adds one regression test per component for the load-more-with-items case.
|
/review |
|
The PR is well-considered. Let me check one last concern — the Let me check potential issues with the One minor thing I want to verify — in Overall assessment: The PR is well-executed. The fix:
I see no real bugs. The author also already responded thoughtfully to Copilot's review and added a regression test. The implementation is correct. LGTM! |
What does this PR do?
Wire the media library admin page and the media picker modal (used by the rich text editor and image fields when embedding media into content) to cursor-based infinite scroll, so libraries with more than 50 items are fully browsable / selectable. Mirrors the content-list fix from #135.
Closes (no existing issue — filed alongside this PR; happy to open one if preferred)
Root cause
The backend
MediaRepository.findMany(inpackages/core/src/database/repositories/media.ts) already supports cursor pagination withnextCursor, defaulting tolimit: 50(max 100). Two admin call-sites never used the cursor:MediaPageinpackages/admin/src/router.tsx—fetchMediaList()called with no arguments. Only the first 50 items ever loaded; library was unbrowsable past the first page.MediaPickerModalinpackages/admin/src/components/MediaPickerModal.tsx—useQuerywith hardcodedlimit: 50. The same ceiling applied when picking media to embed into content from the rich text editor or image fields.MediaLibraryhad nohasMore/onLoadMoreprops either, so there was no Load More UI to wire to.Real-world hit: a library with ~820 ready media items only ever showed 50, both in the admin media page and in any media picker (so embedding media beyond the most recent 50 in posts was impossible).
Fix
router.tsxMediaPage: switch fromuseQuerytouseInfiniteQuerywithlimit: 100, flattendata.pages.flatMap(p => p.items), passhasMore/onLoadMorethrough toMediaLibrary.MediaLibrary.tsx: addhasMore?: booleanandonLoadMore?: () => voidprops; render a "Load More" button under the grid/list (local provider only — external providers continue to manage their own pagination internally).MediaPickerModal.tsx: switch the local-library query touseInfiniteQuerywithlimit: 100; update the dimensions mutation'ssetQueryDatato walk every infinite page rather than the legacy{ items, nextCursor }shape; render a Load More button under the picker grid (local provider only).Type of change
Checklist
pnpm typecheckpasses (modified files: 0 new errors; pre-existing errors inBlockKitFieldWidget.tsx/PortableTextEditor.tsxare unrelated)pnpm lintpasses (oxlint: 0 warnings, 0 errors on changed files)pnpm testpasses fortests/components/MediaLibrary.test.tsx(12/12 — 3 new cases for Load More) andtests/components/MediaPickerModal.test.tsx(17/17 — 3 new cases for Load More)pnpm formathas been runLoad More,Loading...) are wrapped with `t``AI-generated code disclosure
Notes
fetchProviderMedia, so the new "Load More" button is gated onactiveProvider === "local"in both call-sites.messages.poupdates included (per CONTRIBUTING — extracted on merge).