Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
7 changes: 5 additions & 2 deletions apps/server/src/hooks/use-media-source-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,13 @@ export function createServerTransport(
return false;
}
if (id === "*") {
return pathname === "/search";
return pathname === "/search" || pathname === "/v2/search";
}
return (
pathname === `/sources/${id}` || pathname.startsWith(`/sources/${id}/`)
pathname === `/sources/${id}` ||
pathname.startsWith(`/sources/${id}/`) ||
pathname === `/v2/sources/${id}` ||
pathname.startsWith(`/v2/sources/${id}/`)
Comment thread
hmjn023 marked this conversation as resolved.
Outdated
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export const Route = createFileRoute(
}

return new Response(file, {
headers: { "Content-Type": "image/webp" },
headers: {
"Cache-Control": "private, max-age=300",
"Content-Type": "image/webp",
},
});
},
},
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/routes/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ function SearchRoute() {
onSelectSource={(id) => setSearchState("selectedSource", id)}
page={page}
presetClient={PresetClient}
renderMediaItem={(media) => <MediaGridItem media={media} />}
renderMediaItem={(media, options) => (
<MediaGridItem media={media} priority={options?.priority} />
)}
renderNavActions={({ openMobileFilters }) => (
<Button
class="size-11 border-input text-foreground hover:bg-accent md:hidden"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export function SourceMediaPage(
renderItem={(media, options) => (
<MediaGridItem
media={media}
priority={options.priority}
routeVersion={props.variant === "v2" ? "v2" : "default"}
onContextMenu={options.onContextMenu}
isBulkSelectMode={options.isBulkSelectMode}
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/routes/v2/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ import {
} from "~/presentation/store/search-store";

const SEARCH_RESULTS_REFRESH_DEBOUNCE_MS = 300;
const V2_SEARCH_RESULTS_PER_PAGE = 200;
const PresetClient = createPresetClient(rawPresetClient);

export const Route = createFileRoute("/v2/search")({
ssr: false,
pendingComponent: () => null,
component: V2SearchRoute,
});

Expand All @@ -55,7 +58,9 @@ function V2SearchRoute() {
getSearchCondition,
sortBy: () => searchState.sortBy,
sortOrder: () => searchState.sortOrder,
limit: () => searchState.limit,
// V2 can display up to eight columns. Fetch enough complete rows per page
// so scrolling does not stop for another request every two or three rows.
limit: () => Math.max(searchState.limit, V2_SEARCH_RESULTS_PER_PAGE),
scrollY: () => searchState.scrollY,
setScrollY: (value) => setSearchState("scrollY", value),
setOffset: (value) => setSearchState("offset", value),
Expand Down Expand Up @@ -88,6 +93,7 @@ function V2SearchRoute() {
isSelected={options?.isPreviewSelected}
media={media}
onPreviewSelect={options?.onPreviewSelect}
priority={options?.priority}
routeVersion="v2"
/>
)}
Expand All @@ -112,6 +118,7 @@ function V2SearchRoute() {
/>
)}
selectedSource={searchState.selectedSource}
ssrGuard
sources={page.sources()}
variant="v2"
/>
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/routes/v2/sources/$mediaSourceId/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { createFileRoute } from "@tanstack/solid-router";
import { SourceMediaPage } from "~/routes/sources/$mediaSourceId/components/source-media-page";

export const Route = createFileRoute("/v2/sources/$mediaSourceId/")({
ssr: false,
pendingComponent: () => null,
remountDeps: ({ params }: { params: { mediaSourceId: string } }) => [
params.mediaSourceId,
],
Expand Down
67 changes: 67 additions & 0 deletions apps/server/src/tests/e2e/ui-components.gallery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,70 @@ test("shared Solid UI gallery has no narrow viewport overflow", async ({
fullPage: true,
});
});

test("virtual media grid stays populated and DOM-bounded during fast scrolling", async ({
page,
}) => {
await page.setViewportSize({ width: 1_280, height: 720 });
await page.goto(`${getGalleryUrl()}/?virtual-grid=1`);
await expect(
page.getByRole("heading", {
name: "Virtual media grid performance fixture",
}),
).toBeAttached();

const scroller = page.getByTestId("virtual-grid-scroller");
await expect(scroller.locator("[data-media-id]").first()).toBeVisible();

const samples = await scroller.evaluate(async (element) => {
const sampleRows: Array<{
mounted: number;
visible: number;
loadedVisible: number;
firstVisibleId: string | null;
}> = [];
const settleFrame = () =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);

for (let step = 0; step <= 20; step += 1) {
element.scrollTop =
(element.scrollHeight - element.clientHeight) * (step / 20);
await settleFrame();

const scrollerRect = element.getBoundingClientRect();
const mounted = [
...element.querySelectorAll<HTMLElement>("[data-media-id]"),
];
const visible = mounted.filter((item) => {
const rect = item.getBoundingClientRect();
return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom;
});
const loadedVisible = visible.filter((item) => {
const image = item.querySelector("img");
return image?.complete && image.naturalWidth > 0;
});
sampleRows.push({
mounted: mounted.length,
visible: visible.length,
loadedVisible: loadedVisible.length,
firstVisibleId: visible[0]?.dataset.mediaId ?? null,
});
}

return sampleRows;
});

expect(samples.every((sample) => sample.mounted > 0)).toBe(true);
expect(Math.max(...samples.map((sample) => sample.mounted))).toBeLessThan(
100,
);
expect(samples.every((sample) => sample.visible > 0)).toBe(true);
expect(
samples.every((sample) => sample.loadedVisible === sample.visible),
).toBe(true);
expect(
new Set(samples.map((sample) => sample.firstVisibleId)).size,
).toBeGreaterThan(10);
});
81 changes: 80 additions & 1 deletion apps/server/src/tests/e2e/ui-gallery/src.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Media } from "@solid-imager/core/domain/media/schemas";
import {
AlertDialog,
AlertDialogAction,
Expand Down Expand Up @@ -76,6 +77,7 @@ import {
SelectValue,
} from "@solid-imager/ui/select";
import { Skeleton } from "@solid-imager/ui/skeleton";
import { SourceMediaGrid } from "@solid-imager/ui/source-media-grid";
import {
Switch,
SwitchControl,
Expand All @@ -102,6 +104,80 @@ import { render } from "solid-js/web";
import "../../../app.css";

const OPTIONS = ["Alpha", "Beta", "Gamma"];
const VIRTUAL_GRID_ITEM_COUNT = 1_000;
const THUMBNAIL_DATA_URL =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='120' viewBox='0 0 160 120'%3E%3Crect width='160' height='120' fill='%23dbeafe'/%3E%3Cpath d='M0 100 45 55l30 30 22-22 63 57H0Z' fill='%2393c5fd'/%3E%3C/svg%3E";

function createVirtualGridMedia(index: number): Media {
const timestamp = new Date("2026-01-01T00:00:00.000Z");
return {
id: `00000000-0000-4000-8000-${index.toString().padStart(12, "0")}`,
mediaSourceId: "00000000-0000-4000-8000-000000000001",
filePath: `/fixture/${index}.webp`,
fileName: `fixture-${index}.webp`,
mediaType: "image",
width: 160,
height: 120,
fileSize: 1_024,
description: null,
createdAt: timestamp,
modifiedAt: timestamp,
indexedAt: timestamp,
status: "active",
};
}

const VIRTUAL_GRID_MEDIA = Array.from(
{ length: VIRTUAL_GRID_ITEM_COUNT },
(_, index) => createVirtualGridMedia(index),
);

function VirtualGridGallery() {
return (
<main class="h-dvh bg-background p-4 text-foreground">
<h1 class="sr-only">Virtual media grid performance fixture</h1>
<div
class="h-full min-h-0 overflow-y-auto overscroll-contain"
data-media-scroll
data-testid="virtual-grid-scroller"
>
<SourceMediaGrid
disableContextMenu
enableVirtualization
isFetchingNextPage={false}
itemAspectRatio={4 / 3}
mediaResults={() => VIRTUAL_GRID_MEDIA}
mediaSourceId={() => VIRTUAL_GRID_MEDIA[0]?.mediaSourceId}
renderItem={(media, options) => (
<a
class="block aspect-[4/3] overflow-hidden rounded-md bg-muted"
data-media-id={media.id}
href={`#${media.id}`}
onContextMenu={options.onContextMenu}
>
<img
alt={media.fileName}
class="h-full w-full object-cover"
loading={options.priority ? "eager" : "lazy"}
src={THUMBNAIL_DATA_URL}
/>
</a>
)}
scrollMode="element"
setLoadMoreRef={() => undefined}
showResultCount={false}
state={() => ({
data: VIRTUAL_GRID_MEDIA,
error: undefined,
fetchState: "idle",
phase: "data",
})}
totalCount={VIRTUAL_GRID_ITEM_COUNT}
/>
</div>
</main>
);
}

function Gallery() {
const [dark, setDark] = createSignal(false);
Expand Down Expand Up @@ -327,4 +403,7 @@ const root = document.querySelector("#root");
if (!(root instanceof HTMLElement)) {
throw new Error("Gallery root element was not found");
}
render(() => <Gallery />, root);
const showVirtualGrid = new URLSearchParams(window.location.search).has(
"virtual-grid",
);
render(() => (showVirtualGrid ? <VirtualGridGallery /> : <Gallery />), root);
3 changes: 2 additions & 1 deletion apps/tauri/src/routes/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ function SearchRoute() {
onSelectSource={(id) => setSearchState("selectedSource", id)}
page={page}
presetClient={PresetClient}
renderMediaItem={(media) => (
renderMediaItem={(media, options) => (
<MediaGridItem
media={media}
priority={options?.priority}
sourceRootPath={page.getSourceRootPath(media.mediaSourceId)}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,11 @@ export function SourceMediaPage() {
ipsQueryOptions={allIpsQueryOptions}
charactersQueryOptions={allCharactersQueryOptions}
authorsQueryOptions={allAuthorsQueryOptions}
renderItem={(media, { onContextMenu }) => (
renderItem={(media, { onContextMenu, priority }) => (
<MediaGridItem
media={media}
onContextMenu={onContextMenu}
priority={priority}
sourceRootPath={sourceRootPathResolver(mediaSourceId())}
/>
)}
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/query-options/search-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
buildSearchResultsQueryOptions,
buildSourceMediaResultsQueryOptions,
isSourceMediaResultsQueryKey,
MEDIA_RESULTS_STALE_TIME_MS,
removeMediaFromInfiniteQueryData,
searchQueryKeys,
sourceMediaQueryKeys,
Expand Down Expand Up @@ -160,6 +161,7 @@ describe("buildSearchResultsQueryOptions", () => {
expect(options.initialPageParam).toBe(0);
expect(options.enabled).toBe(false);
expect(options.gcTime).toBe(12_345);
expect(options.staleTime).toBe(MEDIA_RESULTS_STALE_TIME_MS);

const queryFn = options.queryFn;
if (typeof queryFn !== "function") {
Expand Down Expand Up @@ -346,6 +348,7 @@ describe("buildSourceMediaResultsQueryOptions", () => {
]);
expect(options.initialPageParam).toBe(0);
expect(options.enabled).toBe(true);
expect(options.staleTime).toBe(MEDIA_RESULTS_STALE_TIME_MS);

const queryFn = options.queryFn;
if (typeof queryFn !== "function") {
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/query-options/search-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
keepPreviousData,
} from "@tanstack/solid-query";

export const MEDIA_RESULTS_STALE_TIME_MS = 1000 * 60 * 5;

export type SearchResultsQueryKeyInput = {
mode: "simple" | "pro" | "vector";
sourceId: string | undefined;
Expand Down Expand Up @@ -187,6 +189,7 @@ export function buildSearchResultsQueryOptions(
placeholderData: input.mode === "vector" ? undefined : keepPreviousData,
enabled: input.enabled,
gcTime: input.gcTime,
staleTime: MEDIA_RESULTS_STALE_TIME_MS,
});
}

Expand Down Expand Up @@ -238,5 +241,6 @@ export function buildSourceMediaResultsQueryOptions(
},
placeholderData: keepPreviousData,
enabled: input.enabled,
staleTime: MEDIA_RESULTS_STALE_TIME_MS,
});
}
10 changes: 8 additions & 2 deletions packages/ui/src/screens/search-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ export type SearchScreenProps = {
renderNavActions?: (actions: SearchScreenNavActions) => JSX.Element;
renderMediaItem: (
media: Media,
options?: { onPreviewSelect?: () => void; isPreviewSelected?: boolean },
options?: {
onPreviewSelect?: () => void;
isPreviewSelected?: boolean;
priority?: boolean;
},
) => JSX.Element;
renderMediaPreview?: (media: Media) => JSX.Element;
onOpenMediaDetail?: (media: Media) => void;
Expand Down Expand Up @@ -148,7 +152,9 @@ export function SearchScreen(props: SearchScreenProps) {
await page().searchResultQuery.refetch();
}}
hasNextPage={page().searchResultQuery.hasNextPage}
renderItem={(media, _options) => props.renderMediaItem(media)}
renderItem={(media, options) =>
props.renderMediaItem(media, options)
}
setLoadMoreRef={page().setLoadMoreRef}
showEmptyState
showResultCount
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/screens/source-media-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type SourceMediaScreenProps = {
media: Media,
options: {
onContextMenu: () => void;
priority?: boolean;
isBulkSelectMode?: boolean;
isSelected?: boolean;
onPreviewSelect?: () => void;
Expand Down
Loading