Skip to content

fix(admin): use infinite scroll for media library to support large libraries#996

Merged
ascorbic merged 3 commits into
emdash-cms:mainfrom
r2sake:fix/media-library-infinite-scroll
May 12, 2026
Merged

fix(admin): use infinite scroll for media library to support large libraries#996
ascorbic merged 3 commits into
emdash-cms:mainfrom
r2sake:fix/media-library-infinite-scroll

Conversation

@r2sake
Copy link
Copy Markdown
Contributor

@r2sake r2sake commented May 12, 2026

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 (in packages/core/src/database/repositories/media.ts) already supports cursor pagination with nextCursor, defaulting to limit: 50 (max 100). Two admin call-sites never used the cursor:

  1. MediaPage in packages/admin/src/router.tsxfetchMediaList() called with no arguments. Only the first 50 items ever loaded; library was unbrowsable past the first page.
  2. MediaPickerModal in packages/admin/src/components/MediaPickerModal.tsxuseQuery with hardcoded limit: 50. The same ceiling applied when picking media to embed into content from the rich text editor or image fields.

MediaLibrary had no hasMore / onLoadMore props 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.tsx MediaPage: switch from useQuery to useInfiniteQuery with limit: 100, flatten data.pages.flatMap(p => p.items), pass hasMore / onLoadMore through to MediaLibrary.
  • MediaLibrary.tsx: add hasMore?: boolean and onLoadMore?: () => void props; 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 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).

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (modified files: 0 new errors; pre-existing errors in BlockKitFieldWidget.tsx / PortableTextEditor.tsx are unrelated)
  • pnpm lint passes (oxlint: 0 warnings, 0 errors on changed files)
  • pnpm test passes for tests/components/MediaLibrary.test.tsx (12/12 — 3 new cases for Load More) and tests/components/MediaPickerModal.test.tsx (17/17 — 3 new cases for Load More)
  • pnpm format has been run
  • I have added/updated tests for my changes
  • User-visible strings (Load More, Loading...) are wrapped with `t``
  • I have added a changeset
  • New features link to an approved Discussion — n/a

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.7

Notes

  • The fix scope is intentionally narrow and parallels fix(admin): use infinite scroll for content list to support large dat… #135. External media providers (CF Images etc.) already handle their own pagination via fetchProviderMedia, so the new "Load More" button is gated on activeProvider === "local" in both call-sites.
  • No locale/messages.po updates included (per CONTRIBUTING — extracted on merge).

…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).
Copilot AI review requested due to automatic review settings May 12, 2026 04:22
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 12, 2026

🦋 Changeset detected

Latest commit: ad15161

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@emdash-cms/admin Patch
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 12, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@996

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@996

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@996

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@996

emdash

npm i https://pkg.pr.new/emdash@996

create-emdash

npm i https://pkg.pr.new/create-emdash@996

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@996

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@996

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@996

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@996

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@996

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@996

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@996

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@996

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@996

commit: ad15161

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 MediaPage from useQuery to useInfiniteQuery and flatten paged results into a single items array.
  • Extend MediaLibrary with hasMore / onLoadMore props 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}
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, 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.

Comment on lines +468 to +473
{/* 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>
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.

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
@github-actions github-actions Bot added size/L and removed size/M labels May 12, 2026
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.
@ascorbic
Copy link
Copy Markdown
Collaborator

/review

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 12, 2026

The PR is well-considered. Let me check one last concern — the fetchMediaList mimeType parameter in the picker. The picker passes filters to mimeType, but the cache key only uses filters?.join(","). So when filters changes, the cache key changes and a fresh chain begins. Good.

Let me check potential issues with the placeholderData for infinite query — it's not set, so when switching filters there will be a brief loader. That's pre-existing behavior, consistent with #135.

One minor thing I want to verify — in MediaLibrary.tsx, the currentLoading empty-state check at line 357 uses both currentItems.length === 0 && currentProviderItems.length === 0. Since activeProvider is either "local" or non-local, only one of these can be non-empty. So the second check is redundant when activeProvider === "local" (currentProviderItems is always [] per line 222) and vice versa. Not a bug, just slightly redundant. Not worth a comment.

Overall assessment:

The PR is well-executed. The fix:

  1. Correctly mirrors the existing ContentList infinite scroll pattern (fix(admin): use infinite scroll for content list to support large dat… #135).
  2. Addresses Copilot's review feedback (gating the centered loader on items.length === 0).
  3. Properly updates the setQueryData updater for the new pages-shape.
  4. Adds focused regression tests.
  5. Gates Load More on activeProvider === "local" (external providers handle their own pagination).
  6. Has a changeset.

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!

github run

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

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

Thanks!

@ascorbic ascorbic merged commit 19576be into emdash-cms:main May 12, 2026
35 checks passed
@emdashbot emdashbot Bot mentioned this pull request May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants