diff --git a/.changeset/media-library-lazy-thumbnails.md b/.changeset/media-library-lazy-thumbnails.md new file mode 100644 index 0000000000..874723b8a7 --- /dev/null +++ b/.changeset/media-library-lazy-thumbnails.md @@ -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. diff --git a/packages/admin/src/components/MediaLibrary.tsx b/packages/admin/src/components/MediaLibrary.tsx index ee01231531..e0433118c6 100644 --- a/packages/admin/src/components/MediaLibrary.tsx +++ b/packages/admin/src/components/MediaLibrary.tsx @@ -722,6 +722,7 @@ function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) { {item.alt fallbackToOriginalThumbnail(e.currentTarget, item.url)} /> @@ -773,6 +774,7 @@ function ProviderGridItem({ item, selected, onClick, onDimensionsLoaded }: Provi {item.alt @@ -815,6 +817,7 @@ function MediaListItem({ item, selected, onClick }: MediaListItemProps) { {item.alt fallbackToOriginalThumbnail(e.currentTarget, item.url)} /> @@ -870,6 +873,7 @@ function ProviderListItem({ item, selected, onClick, onDimensionsLoaded }: Provi {item.alt diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 4b8fb9530f..1c1af21a17 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -1312,7 +1312,7 @@ function MediaPage() { queryFn: ({ pageParam }) => fetchMediaList({ cursor: pageParam, - limit: 100, + limit: 40, search: search || undefined, mimeType: mimeFilter, }), diff --git a/packages/admin/tests/components/MediaLibrary.test.tsx b/packages/admin/tests/components/MediaLibrary.test.tsx index 555fdd927d..7e54ad1e0a 100644 --- a/packages/admin/tests/components/MediaLibrary.test.tsx +++ b/packages/admin/tests/components/MediaLibrary.test.tsx @@ -3,8 +3,8 @@ import * as React from "react"; 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"; // --------------------------------------------------------------------------- @@ -90,6 +90,54 @@ describe("MediaLibrary", () => { 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 { + 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", () => { @@ -268,6 +316,13 @@ describe("MediaLibrary", () => { 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"); + }); }); describe("header", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d556eb31dc..fb92bee22e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -468,6 +468,40 @@ importers: specifier: 'catalog:' version: 4.100.0(@cloudflare/workers-types@4.20260305.1) + demos/emdash-dev-main: + dependencies: + '@astrojs/cloudflare': + specifier: 'catalog:' + version: 14.0.0(@types/node@26.1.1)(astro@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(rollup@4.55.2)(tsx@4.21.0)(yaml@2.9.0))(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(workerd@1.20260611.1)(wrangler@4.100.0(@cloudflare/workers-types@4.20260305.1))(yaml@2.9.0) + '@astrojs/react': + specifier: 'catalog:' + version: 6.0.0(@types/node@26.1.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.9.0) + '@emdash-cms/cloudflare': + specifier: workspace:* + version: link:../../packages/cloudflare + astro: + specifier: 'catalog:' + version: 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@26.1.1)(aws4fetch@1.0.20)(jiti@2.7.0)(rollup@4.55.2)(tsx@4.21.0)(yaml@2.9.0) + emdash: + specifier: workspace:* + version: link:../../packages/core + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + devDependencies: + '@astrojs/check': + specifier: 'catalog:' + version: 0.9.7(prettier-plugin-astro@0.14.1)(prettier@3.9.5)(typescript@6.0.0-beta) + '@cloudflare/workers-types': + specifier: 'catalog:' + version: 4.20260305.1 + wrangler: + specifier: 'catalog:' + version: 4.100.0(@cloudflare/workers-types@4.20260305.1) + demos/playground: dependencies: '@astrojs/cloudflare':