Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion apps/server/src/routes/v2/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function V2SearchRoute() {
similarityTopK: () => searchState.similarityTopK,
refreshDebounceMs: SEARCH_RESULTS_REFRESH_DEBOUNCE_MS,
isSearchStateRestored,
scrollContainerSelector: "[data-media-scroll]",
scrollContainerSelector: '[data-media-scroll="v2-search"]',
});

useMediaSourceEvents(() => searchState.selectedSource || "*", {
Expand Down
18 changes: 18 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 @@ -258,6 +258,24 @@ test("virtual media grid stays populated and DOM-bounded during fast scrolling",
expect(transferSummary.retransferredUrls).toEqual([]);
});

test("virtual media grid stays populated after restoring a deep scroll position", async ({
page,
}) => {
await page.setViewportSize({ width: 1_280, height: 720 });
await page.goto(`${getGalleryUrl()}/?virtual-grid=1&restore-grid=1`);

const scroller = page.getByTestId("virtual-grid-scroller");
await expect
.poll(() => scroller.evaluate((element) => element.scrollTop))
.toBeGreaterThan(1_000);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
await expectVisibleImagesLoaded(scroller);

await scroller.evaluate((element) => {
element.scrollTop += element.clientHeight * 2;
});
await expectVisibleImagesLoaded(scroller);
});

test.describe("high-density virtual media grid", () => {
test.use({ deviceScaleFactor: 2 });

Expand Down
43 changes: 39 additions & 4 deletions apps/server/src/tests/e2e/ui-gallery/src.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@solid-imager/ui/dialog";
import { useScrollRestoration } from "@solid-imager/ui/hooks/scroll-container";
import { Input } from "@solid-imager/ui/input";
import {
Popover,
Expand Down Expand Up @@ -132,6 +133,38 @@ const VIRTUAL_GRID_MEDIA = Array.from(
);

function VirtualGridGallery() {
const restoreGrid = new URLSearchParams(window.location.search).has(
"restore-grid",
);
const [loadedCount, setLoadedCount] = createSignal(
restoreGrid ? 200 : VIRTUAL_GRID_ITEM_COUNT,
);
const [isFetchingNextPage, setIsFetchingNextPage] = createSignal(false);
const mediaResults = () => VIRTUAL_GRID_MEDIA.slice(0, loadedCount());
const fetchNextPage = async () => {
if (
!restoreGrid ||
isFetchingNextPage() ||
loadedCount() >= VIRTUAL_GRID_ITEM_COUNT
) {
return;
}
setIsFetchingNextPage(true);
await new Promise((resolve) => setTimeout(resolve, 10));
setLoadedCount((count) => Math.min(count + 200, VIRTUAL_GRID_ITEM_COUNT));
setIsFetchingNextPage(false);
};
useScrollRestoration({
restoreKey: () => "virtual-grid",
getPosition: () => (restoreGrid ? 20_000 : 0),
setPosition: () => undefined,
isReady: () => mediaResults().length > 0,
hasNextPage: () => loadedCount() < VIRTUAL_GRID_ITEM_COUNT,
isFetchingNextPage,
fetchNextPage,
scrollContainerSelector: "[data-media-scroll]",
});

return (
<main class="h-dvh bg-background p-4 text-foreground">
<h1 class="sr-only">Virtual media grid performance fixture</h1>
Expand All @@ -143,10 +176,12 @@ function VirtualGridGallery() {
<SourceMediaGrid
disableContextMenu
enableVirtualization
isFetchingNextPage={false}
isFetchingNextPage={isFetchingNextPage()}
itemAspectRatio={4 / 3}
mediaResults={() => VIRTUAL_GRID_MEDIA}
mediaSourceId={() => VIRTUAL_GRID_MEDIA[0]?.mediaSourceId}
hasNextPage={loadedCount() < VIRTUAL_GRID_ITEM_COUNT}
mediaResults={mediaResults}
mediaSourceId={() => mediaResults()[0]?.mediaSourceId}
onLoadMore={fetchNextPage}
renderItem={(media, options) => (
// The immutable, uniquely-addressed URLs exercise the browser's real
// memory cache when a virtual row is unmounted and revisited.
Expand Down Expand Up @@ -177,7 +212,7 @@ function VirtualGridGallery() {
setLoadMoreRef={() => undefined}
showResultCount={false}
state={() => ({
data: VIRTUAL_GRID_MEDIA,
data: mediaResults(),
error: undefined,
fetchState: "idle",
phase: "data",
Expand Down
8 changes: 7 additions & 1 deletion packages/ui/src/hooks/scroll-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,13 @@ export function useScrollRestoration(
}

const cancelRestore = () => {
if (!isRestored()) cancelled = true;
if (!isRestored()) {
cancelled = true;
// User input takes ownership of the scroll position. Mark the
// restoration complete so cleanup persists the position they chose
// instead of treating it as an unfinished restore.
setIsRestored(true);
}
};
window.addEventListener("pointerdown", cancelRestore, { passive: true });
window.addEventListener("wheel", cancelRestore, { passive: true });
Expand Down
13 changes: 13 additions & 0 deletions packages/ui/src/hooks/use-current-search-persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,19 @@ describe("useCurrentSearchPersistence", () => {
expect(searchState.selectedSource).toBe("saved-source");
});

it("preserves the current scroll position while restoring search state", async () => {
setSearchState("scrollY", 1840);
sessionStorage.setItem(
"current-all",
JSON.stringify(createPersistedSimpleState("saved query")),
);

mountPersistence("all");
await flushMicrotasks();

expect(searchState.scrollY).toBe(1840);
});

it("does not inherit a source for legacy vector sessions", async () => {
setSearchState("selectedSource", "stale-source");
sessionStorage.setItem(
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/hooks/use-current-search-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ function applyPreset(
if (!shouldApply()) {
return;
}
const preservedScrollY = searchState.scrollY;
resetSearchState();
loadPreset(preset);
setSearchState("selectedSource", selectedSource);
setSearchState({ selectedSource, scrollY: preservedScrollY });
if (clearActivePreset) {
setSearchState("activePresetId", null);
}
Expand Down Expand Up @@ -102,6 +103,7 @@ function restoreCurrentSearchState(
if (!shouldApply()) {
return;
}
const preservedScrollY = searchState.scrollY;
const selectedSource =
typeof current.selectedSource === "string" ? current.selectedSource : "";
resetSearchState();
Expand All @@ -116,6 +118,7 @@ function restoreCurrentSearchState(
current.similarityTopK === 20 || current.similarityTopK === 100
? current.similarityTopK
: 50,
scrollY: preservedScrollY,
});
return;
}
Expand Down
43 changes: 23 additions & 20 deletions packages/ui/src/screens/v2-search-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,32 +45,35 @@ export function V2SearchScreen(props: SearchScreenProps) {
sourceName={sourceName()}
sources={props.sources}
/>
<div class="shrink-0 px-3 pt-3 sm:px-4">
<Show
when={filterStates().some(
(state) => state.phase === "error" || state.phase === "offline",
)}
>
<FilterErrorBanner
class="mb-2"
message="一部の検索フィルターを取得できませんでした。検索結果は引き続き利用できます。"
onRetry={page().retryFilters}
/>
</Show>
<div class="h-8">
<QueryStatus
fetchState={page().contentState().fetchState}
hasData={page().contentState().data !== undefined}
hideWhenIdle
offlineLabel="オフラインのため保存済みの検索結果を表示しています"
updatingLabel="検索結果を更新中..."
/>
</div>
</div>

<div
class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 sm:px-4 [scrollbar-gutter:stable]"
data-media-scroll
data-media-scroll="v2-search"
>
<div class="2xl:grid 2xl:grid-cols-[minmax(0,1fr)_clamp(20rem,26vw,26rem)] 2xl:items-start 2xl:gap-4">
<div class="min-w-0">
<Show
when={filterStates().some(
(state) => state.phase === "error" || state.phase === "offline",
)}
>
<FilterErrorBanner
class="mb-3"
message="一部の検索フィルターを取得できませんでした。検索結果は引き続き利用できます。"
onRetry={page().retryFilters}
/>
</Show>
<QueryStatus
class="mb-2"
fetchState={page().contentState().fetchState}
hasData={page().contentState().data !== undefined}
hideWhenIdle
offlineLabel="オフラインのため保存済みの検索結果を表示しています"
updatingLabel="検索結果を更新中..."
/>
<Show
fallback={
<LoadingRegion label="検索結果を読み込んでいます...">
Expand Down
43 changes: 23 additions & 20 deletions packages/ui/src/screens/v2-source-media-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,32 +81,35 @@ export function V2SourceMediaScreen(props: SourceMediaScreenProps) {
<Show when={props.renderJobProgress && page().jobProgress()}>
{props.renderJobProgress?.({ jobProgress: page().jobProgress })}
</Show>
<div class="shrink-0 px-3 pt-3 sm:px-4">
<Show
when={filterStates().some(
(state) => state.phase === "error" || state.phase === "offline",
)}
>
<FilterErrorBanner
class="mb-2"
message="一部の検索フィルターを取得できませんでした。メディア一覧は引き続き利用できます。"
onRetry={props.onRetryFilters}
/>
</Show>
<div class="h-8">
<QueryStatus
fetchState={page().contentState().fetchState}
hasData={page().contentState().data !== undefined}
hideWhenIdle
offlineLabel="オフラインのため保存済みデータを表示しています"
updatingLabel="メディア一覧を更新中..."
/>
</div>
</div>

<div
class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 sm:px-4 [scrollbar-gutter:stable]"
data-media-scroll
data-media-scroll={page().mediaSourceId() ?? "v2-source"}
>
<div class="2xl:grid 2xl:grid-cols-[minmax(0,1fr)_clamp(20rem,26vw,26rem)] 2xl:items-start 2xl:gap-4">
<div class="min-w-0">
<Show
when={filterStates().some(
(state) => state.phase === "error" || state.phase === "offline",
)}
>
<FilterErrorBanner
class="mb-3"
message="一部の検索フィルターを取得できませんでした。メディア一覧は引き続き利用できます。"
onRetry={props.onRetryFilters}
/>
</Show>
<QueryStatus
class="mb-2"
fetchState={page().contentState().fetchState}
hasData={page().contentState().data !== undefined}
hideWhenIdle
offlineLabel="オフラインのため保存済みデータを表示しています"
updatingLabel="メディア一覧を更新中..."
/>
<Show
fallback={
<LoadingRegion label="メディア一覧を読み込んでいます...">
Expand Down
20 changes: 17 additions & 3 deletions packages/ui/src/source-media-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,10 @@ export function SourceMediaGrid(props: SourceMediaGridProps) {
): MediaGridImageLoadPolicy => {
const state = elementLoadState();
if (!state) {
return { enabled: false };
// A virtualizer can briefly have no measured range while its scroll
// container is being restored. Keep mounted rows loadable so a
// transient range reset does not blank the entire viewport.
return { enabled: true, loading: "eager" };
}

const isVisible =
Expand All @@ -264,14 +267,19 @@ export function SourceMediaGrid(props: SourceMediaGridProps) {
const isPrefetch = isForwardPrefetch || isBackwardPrefetch;

return {
enabled: isVisible || isPrefetch,
// The element virtualizer already bounds the DOM to the visible range
// plus a small directional buffer. Keep every mounted row loadable:
// during navigation restoration the virtualizer can report the previous
// range for one frame, and disabling those rows leaves the viewport blank
// when the user scrolls again.
enabled: true,
fetchpriority:
isVisible && mediaIndex < INITIAL_HIGH_PRIORITY_MEDIA
? "high"
: isPrefetch
? "low"
: undefined,
loading: isVisible || isPrefetch ? "eager" : "lazy",
loading: "eager",
};
};
const createElementImageLoadPolicy = (
Expand Down Expand Up @@ -334,6 +342,12 @@ export function SourceMediaGrid(props: SourceMediaGridProps) {
rowCount();
mediaItemHeight();
columnCount();
// The element virtualizer is created before the grid's mount callback
// discovers its nested scroller. Re-measure when that scroller or the
// content offset becomes available so the initial range is populated
// without requiring a user scroll event.
scrollElement();
scrollMargin();
mediaRowVirtualizer().measure();
});

Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/source-media-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ export function SourceMediaPage(props: SourceMediaPageProps): JSX.Element {
onThumbnailReady: props.onThumbnailReady,
isSearchStateRestored,
scrollContainerSelector:
props.variant === "v2" ? "[data-media-scroll]" : undefined,
props.variant === "v2"
? `[data-media-scroll="${props.mediaSourceId()}"]`
: undefined,
Comment thread
hmjn023 marked this conversation as resolved.
Resolved
});

const renderActions: SourceMediaScreenProps["renderActions"] = (actions) => (
Expand Down