Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/media-library-lazy-thumbnails.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes slow load and scroll on the Media Library admin page. Thumbnails now use native `loading="lazy"` so the browser only fetches images as they scroll into view, instead of firing every visible page's worth of resize requests on mount. The initial page size is also reduced from 100 to 40 items.
4 changes: 4 additions & 0 deletions packages/admin/src/components/MediaLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,7 @@ function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) {
<img
src={getMediaThumbnailUrl(item.url, item.mimeType, MEDIA_THUMBNAIL_WIDTH)}
alt={item.alt || item.filename}
loading="lazy"
className="h-full w-full object-cover"
onError={(e) => fallbackToOriginalThumbnail(e.currentTarget, item.url)}
/>
Expand Down Expand Up @@ -773,6 +774,7 @@ function ProviderGridItem({ item, selected, onClick, onDimensionsLoaded }: Provi
<img
src={item.previewUrl}
alt={item.alt || item.filename}
loading="lazy"
className="h-full w-full object-cover"
onLoad={handleImageLoad}
/>
Expand Down Expand Up @@ -815,6 +817,7 @@ function MediaListItem({ item, selected, onClick }: MediaListItemProps) {
<img
src={getMediaThumbnailUrl(item.url, item.mimeType, 80)}
alt={item.alt || item.filename}
loading="lazy"
className="h-full w-full object-cover"
onError={(e) => fallbackToOriginalThumbnail(e.currentTarget, item.url)}
/>
Expand Down Expand Up @@ -870,6 +873,7 @@ function ProviderListItem({ item, selected, onClick, onDimensionsLoaded }: Provi
<img
src={item.previewUrl}
alt={item.alt || item.filename}
loading="lazy"
className="h-full w-full object-cover"
onLoad={handleImageLoad}
/>
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1312,7 +1312,7 @@ function MediaPage() {
queryFn: ({ pageParam }) =>
fetchMediaList({
cursor: pageParam,
limit: 100,
limit: 40,
search: search || undefined,
mimeType: mimeFilter,
}),
Expand Down
59 changes: 57 additions & 2 deletions packages/admin/tests/components/MediaLibrary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

import { MediaLibrary } from "../../src/components/MediaLibrary";
import type { MediaItem } from "../../src/lib/api";
import { deleteMedia } from "../../src/lib/api";
import { deleteMedia, fetchMediaProviders, fetchProviderMedia } from "../../src/lib/api";
import type { MediaItem, MediaProviderItem } from "../../src/lib/api";
import { render } from "../utils/render.tsx";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -90,6 +90,54 @@
await expect.element(img).toBeInTheDocument();
await expect.element(img).toHaveAttribute("src", "https://example.com/photo.jpg");
});

it("grid thumbnails are natively lazy-loaded", async () => {
// A library page can hold up to 40 items; without `loading="lazy"` the
// browser fetches every thumbnail on mount instead of only the visible ones.
const items = [makeMediaItem({ id: "1", filename: "pic.jpg", mimeType: "image/jpeg" })];
const screen = await renderLibrary({ items });
await expect.element(screen.getByAltText("pic.jpg")).toHaveAttribute("loading", "lazy");
});
});

describe("provider thumbnails", () => {
function makeProviderItem(overrides: Partial<MediaProviderItem> = {}): MediaProviderItem {
return {
id: "provider_01",
filename: "cloud-pic.jpg",
mimeType: "image/jpeg",
previewUrl: "https://provider.example.com/cloud-pic.jpg",
width: 800,
height: 600,
...overrides,
};
}

function mockProvider(items: MediaProviderItem[]) {
vi.mocked(fetchMediaProviders).mockResolvedValue([
{
id: "acme",
name: "Acme Provider",
capabilities: { upload: false, search: false, delete: false },
},
]);
vi.mocked(fetchProviderMedia).mockResolvedValue({ items });
}

it("provider grid thumbnails are natively lazy-loaded", async () => {
mockProvider([makeProviderItem()]);
const screen = await renderLibrary();
await screen.getByRole("tab", { name: "Acme Provider" }).click();
await expect.element(screen.getByAltText("cloud-pic.jpg")).toHaveAttribute("loading", "lazy");
});

it("provider list thumbnails are natively lazy-loaded", async () => {
mockProvider([makeProviderItem()]);
const screen = await renderLibrary();
await screen.getByRole("tab", { name: "Acme Provider" }).click();
await screen.getByRole("tab", { name: "List view" }).click();
await expect.element(screen.getByAltText("cloud-pic.jpg")).toHaveAttribute("loading", "lazy");
});
});

describe("view mode toggle", () => {
Expand Down Expand Up @@ -268,6 +316,13 @@
await expect.element(screen.getByText("application/pdf")).toBeInTheDocument();
await expect.element(screen.getByText("1 MB")).toBeInTheDocument();
});

it("list view thumbnails are natively lazy-loaded", async () => {
const items = [makeMediaItem({ id: "1", filename: "pic.jpg", mimeType: "image/jpeg" })];
const screen = await renderLibrary({ items });
await screen.getByRole("tab", { name: "List view" }).click();
await expect.element(screen.getByAltText("pic.jpg")).toHaveAttribute("loading", "lazy");
});
});

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.

[needs fixing] The PR adds loading="lazy" to all four thumbnail <img> tags, including ProviderGridItem and ProviderListItem, but the new tests only assert it for the local grid and list views. Per AGENTS.md's "TDD for bugs" convention, the provider paths should have matching regression coverage too.

Add provider-tab tests analogous to the local ones. This will need fetchMediaProviders and fetchProviderMedia to be imported from ../../src/lib/api so they can be mocked per-test.

Suggested change
});
});
describe("provider thumbnails", () => {
it("provider grid thumbnails are natively lazy-loaded", async () => {
vi.mocked(fetchMediaProviders).mockResolvedValue([
{
id: "demo",
name: "Demo Provider",
capabilities: { browse: true, search: false, upload: false, delete: false },
},
]);
vi.mocked(fetchProviderMedia).mockResolvedValue({
items: [
{
id: "1",
filename: "provider.jpg",
mimeType: "image/jpeg",
previewUrl: "https://example.com/provider.jpg",
},
],
});
const screen = await renderLibrary();
await screen.getByRole("button", { name: "Demo Provider" }).click();
await expect
.element(screen.getByAltText("provider.jpg"))
.toHaveAttribute("loading", "lazy");
});
it("provider list view thumbnails are natively lazy-loaded", async () => {
vi.mocked(fetchMediaProviders).mockResolvedValue([
{
id: "demo",
name: "Demo Provider",
capabilities: { browse: true, search: false, upload: false, delete: false },
},
]);
vi.mocked(fetchProviderMedia).mockResolvedValue({
items: [
{
id: "1",
filename: "provider.jpg",
mimeType: "image/jpeg",
previewUrl: "https://example.com/provider.jpg",
},
],
});
const screen = await renderLibrary();
await screen.getByRole("button", { name: "List view" }).click();
await screen.getByRole("button", { name: "Demo Provider" }).click();
await expect
.element(screen.getByAltText("provider.jpg"))
.toHaveAttribute("loading", "lazy");
});
});


describe("header", () => {
Expand Down Expand Up @@ -383,7 +438,7 @@
await screen.getByRole("option", { name: "Images" }).click();
await screen.getByRole("tab", { name: "Cloudflare Images" }).click();

await expect.element(screen.getByText("No media found")).toBeInTheDocument();

Check failure on line 441 in packages/admin/tests/components/MediaLibrary.test.tsx

View workflow job for this annotation

GitHub Actions / Browser Tests

[chromium] tests/components/MediaLibrary.test.tsx > MediaLibrary > local search and filter > does not keep the local filter toolbar visible on empty provider tabs

VitestBrowserElementError: Cannot find element with locator: page.getByText('No media found') <body style="" > <div> <div class="space-y-4" > <div class="flex flex-wrap items-center justify-between gap-4" > <h1 class="text-2xl font-bold" tabindex="-1" > Media Library </h1> <div class="flex items-center gap-3" /> </div> <div class="relative isolate min-w-0 font-medium" data-activation-direction="right" data-orientation="horizontal" > <div class="relative flex min-w-0 shrink items-stretch gap-4 border-b border-kumo-hairline pb-2 h-7.5" data-activation-direction="right" data-orientation="horizontal" role="tablist" > <button aria-disabled="false" aria-selected="false" class="relative z-2 flex items-center rounded bg-transparent whitespace-nowrap focus:outline-none focus:ring-kumo-focus/50 focus-visible:ring-2 focus-visible:ring-kumo-brand cursor-pointer text-base text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default aria-selected:hover:bg-kumo-tint aria-selected:font-medium aria-selected:text-kumo-default px-2 py-3" data-kumo-component="Tabs" data-kumo-part="tab" data-orientation="horizontal" id="base-ui-_r_7l_" role="tab" tabindex="-1" type="button" > <span class="flex items-center gap-2" > Library </span> </button> <button aria-disabled="false" aria-selected="true" class="relative z-2 flex items-center rounded bg-transparent whitespace-nowrap focus:outline-none focus:ring-kumo-focus/50 focus-visible:ring-2 focus-visible:ring-kumo-brand cursor-pointer text-base text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default aria-selected:hover:bg-kumo-tint aria-selected:font-medium aria-selected:text-kumo-default px-2 py-3" data-active="" data-composite-item-active="" data-kumo-component="Tabs" data-kumo-part="tab" data-orientation="horizontal" id="base-ui-_r_7m_" role="tab" tabindex="0" type="button" > <span class="flex items-center gap-2" > Cloudflare Images </span> </button> <div class="absolute z-1 left-0 w-(--active-tab-width) translate-x-(--active-tab-left) transition-all duration-200 data-[rendered=false]:scale-90 data-[rendered=false]:opacity-0 bottom-0 h-0.5 bg-kumo-brand" data-activation-direction="right" data-orientation="horizontal" role="presentation" style="--active-tab-left: 56.75px; --active-tab-right: 1099.078px; --active-tab-top: 0px; --active-tab-bottom: 0px; --active-tab-width: 124.172px; --active-tab-height: 21px;" /> </div> </div> <div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between" > <div class="flex min-w-0 items-center gap-3" /> <div class="flex flex-shrink-0 items-center justify-between gap-3 sm:justify-end" > <span aria-live="polite" class="text-sm text-kumo-subtle" > 1 item </span> <div aria-label="View mode" role="group" > <div class="relative isolate min-w-0 font-medium rounded-lg ring ring-kumo-hairline/70" data-activation-direction="none" data-orientation="horizontal" > <div … /> <div … /> </div> </div> </div> </div> <div class="grid gap-4 grid-cols-[repeat(auto-fill,minm
expect(screen.getByRole("tab", { name: "Grid view" }).query()).toBeNull();
expect(screen.getByRole("tab", { name: "List view" }).query()).toBeNull();
});
Expand Down
34 changes: 34 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading