From 1fc06044ab1e8dc1bc4e9b23bba9cfee2adf6d93 Mon Sep 17 00:00:00 2001 From: hmjn Date: Sun, 2 Aug 2026 21:47:59 +0900 Subject: [PATCH 01/11] fix(ui): preserve search scroll and recover grid images --- .../hooks/use-current-search-persistence.test.ts | 13 +++++++++++++ .../ui/src/hooks/use-current-search-persistence.ts | 5 ++++- packages/ui/src/source-media-grid.tsx | 5 ++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/hooks/use-current-search-persistence.test.ts b/packages/ui/src/hooks/use-current-search-persistence.test.ts index 2db333f3..427c4290 100644 --- a/packages/ui/src/hooks/use-current-search-persistence.test.ts +++ b/packages/ui/src/hooks/use-current-search-persistence.test.ts @@ -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( diff --git a/packages/ui/src/hooks/use-current-search-persistence.ts b/packages/ui/src/hooks/use-current-search-persistence.ts index a8fed356..99eeb663 100644 --- a/packages/ui/src/hooks/use-current-search-persistence.ts +++ b/packages/ui/src/hooks/use-current-search-persistence.ts @@ -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); } @@ -102,6 +103,7 @@ function restoreCurrentSearchState( if (!shouldApply()) { return; } + const preservedScrollY = searchState.scrollY; const selectedSource = typeof current.selectedSource === "string" ? current.selectedSource : ""; resetSearchState(); @@ -116,6 +118,7 @@ function restoreCurrentSearchState( current.similarityTopK === 20 || current.similarityTopK === 100 ? current.similarityTopK : 50, + scrollY: preservedScrollY, }); return; } diff --git a/packages/ui/src/source-media-grid.tsx b/packages/ui/src/source-media-grid.tsx index b96ace1c..a5ba3e8b 100644 --- a/packages/ui/src/source-media-grid.tsx +++ b/packages/ui/src/source-media-grid.tsx @@ -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: "lazy" }; } const isVisible = From 74980409905a3bfce04e91ac99af25e7c4cca491 Mon Sep 17 00:00:00 2001 From: hmjn Date: Sun, 2 Aug 2026 22:30:00 +0900 Subject: [PATCH 02/11] fix(ui): keep restored virtual grids populated Keep mounted virtual rows loadable while navigation restoration settles and persist user-owned scroll positions when restoration is cancelled. Add a paginated deep-scroll regression fixture covering post-restore scrolling. --- .../tests/e2e/ui-components.gallery.spec.ts | 18 ++++++++ apps/server/src/tests/e2e/ui-gallery/src.tsx | 43 +++++++++++++++++-- packages/ui/src/hooks/scroll-container.ts | 8 +++- packages/ui/src/source-media-grid.tsx | 9 +++- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/apps/server/src/tests/e2e/ui-components.gallery.spec.ts b/apps/server/src/tests/e2e/ui-components.gallery.spec.ts index 98fffe76..69f9901a 100644 --- a/apps/server/src/tests/e2e/ui-components.gallery.spec.ts +++ b/apps/server/src/tests/e2e/ui-components.gallery.spec.ts @@ -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); + 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 }); diff --git a/apps/server/src/tests/e2e/ui-gallery/src.tsx b/apps/server/src/tests/e2e/ui-gallery/src.tsx index 1896dc94..f9ceef65 100644 --- a/apps/server/src/tests/e2e/ui-gallery/src.tsx +++ b/apps/server/src/tests/e2e/ui-gallery/src.tsx @@ -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, @@ -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 (

Virtual media grid performance fixture

@@ -143,10 +176,12 @@ function VirtualGridGallery() { 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. @@ -177,7 +212,7 @@ function VirtualGridGallery() { setLoadMoreRef={() => undefined} showResultCount={false} state={() => ({ - data: VIRTUAL_GRID_MEDIA, + data: mediaResults(), error: undefined, fetchState: "idle", phase: "data", diff --git a/packages/ui/src/hooks/scroll-container.ts b/packages/ui/src/hooks/scroll-container.ts index f83c73cd..1565ed8f 100644 --- a/packages/ui/src/hooks/scroll-container.ts +++ b/packages/ui/src/hooks/scroll-container.ts @@ -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 }); diff --git a/packages/ui/src/source-media-grid.tsx b/packages/ui/src/source-media-grid.tsx index a5ba3e8b..6fc8e6d0 100644 --- a/packages/ui/src/source-media-grid.tsx +++ b/packages/ui/src/source-media-grid.tsx @@ -267,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 = ( From a965cd3faa5eb004529e4c6b0c6ea652a4eccff8 Mon Sep 17 00:00:00 2001 From: hmjn Date: Mon, 3 Aug 2026 00:11:18 +0900 Subject: [PATCH 03/11] fix(ui): initialize virtual grid after scroll restoration Re-measure the nested virtualizer when its scroll container and content offset become available, and eagerly load mounted rows while the initial range is settling. --- packages/ui/src/source-media-grid.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/source-media-grid.tsx b/packages/ui/src/source-media-grid.tsx index 6fc8e6d0..ef7c3781 100644 --- a/packages/ui/src/source-media-grid.tsx +++ b/packages/ui/src/source-media-grid.tsx @@ -251,7 +251,7 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { // 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: "lazy" }; + return { enabled: true, loading: "eager" }; } const isVisible = @@ -342,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(); }); From dc3925f22c3a661118c4ceeda7bce7a11ee1074a Mon Sep 17 00:00:00 2001 From: hmjn Date: Mon, 3 Aug 2026 01:11:07 +0900 Subject: [PATCH 04/11] fix(ui): stabilize v2 search scroll updates --- apps/server/src/routes/v2/search.tsx | 2 +- packages/ui/src/screens/v2-search-screen.tsx | 43 ++++++++++--------- .../ui/src/screens/v2-source-media-screen.tsx | 43 ++++++++++--------- packages/ui/src/source-media-page.tsx | 4 +- 4 files changed, 50 insertions(+), 42 deletions(-) diff --git a/apps/server/src/routes/v2/search.tsx b/apps/server/src/routes/v2/search.tsx index ad67e420..e2b15851 100644 --- a/apps/server/src/routes/v2/search.tsx +++ b/apps/server/src/routes/v2/search.tsx @@ -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 || "*", { diff --git a/packages/ui/src/screens/v2-search-screen.tsx b/packages/ui/src/screens/v2-search-screen.tsx index 5b136748..a2ae6075 100644 --- a/packages/ui/src/screens/v2-search-screen.tsx +++ b/packages/ui/src/screens/v2-search-screen.tsx @@ -45,32 +45,35 @@ export function V2SearchScreen(props: SearchScreenProps) { sourceName={sourceName()} sources={props.sources} /> +
+ state.phase === "error" || state.phase === "offline", + )} + > + + +
+ +
+
- state.phase === "error" || state.phase === "offline", - )} - > - - - diff --git a/packages/ui/src/screens/v2-source-media-screen.tsx b/packages/ui/src/screens/v2-source-media-screen.tsx index 08c28ebc..31f4c970 100644 --- a/packages/ui/src/screens/v2-source-media-screen.tsx +++ b/packages/ui/src/screens/v2-source-media-screen.tsx @@ -81,32 +81,35 @@ export function V2SourceMediaScreen(props: SourceMediaScreenProps) { {props.renderJobProgress?.({ jobProgress: page().jobProgress })} +
+ state.phase === "error" || state.phase === "offline", + )} + > + + +
+ +
+
- state.phase === "error" || state.phase === "offline", - )} - > - - - diff --git a/packages/ui/src/source-media-page.tsx b/packages/ui/src/source-media-page.tsx index c2c2bcad..9e6f30e2 100644 --- a/packages/ui/src/source-media-page.tsx +++ b/packages/ui/src/source-media-page.tsx @@ -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, }); const renderActions: SourceMediaScreenProps["renderActions"] = (actions) => ( From d02df0710fce96be99cd9da88bf16e2a7614e71c Mon Sep 17 00:00:00 2001 From: hmjn Date: Mon, 3 Aug 2026 22:33:46 +0900 Subject: [PATCH 05/11] fix(ui): resync virtual grid after cancelled restoration --- packages/ui/src/hooks/scroll-container.ts | 49 +++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/ui/src/hooks/scroll-container.ts b/packages/ui/src/hooks/scroll-container.ts index 1565ed8f..7d6b1675 100644 --- a/packages/ui/src/hooks/scroll-container.ts +++ b/packages/ui/src/hooks/scroll-container.ts @@ -90,6 +90,8 @@ export function useScrollRestoration( let frameId: number | undefined; let settleTimer: ReturnType | undefined; let restoreGeneration = 0; + let reconcileFrameId: number | undefined; + let secondReconcileFrameId: number | undefined; const requestRestore = () => { if (frameId !== undefined || isServer) return; @@ -212,13 +214,56 @@ export function useScrollRestoration( history.scrollRestoration = "manual"; } + const notifyScrollPosition = () => { + if (options.scrollContainerSelector) { + resolveScrollContainer(options.scrollContainerSelector)?.dispatchEvent( + new Event("scroll"), + ); + return; + } + window.dispatchEvent(new Event("scroll")); + }; + + const reconcileVirtualizerPosition = () => { + notifyScrollPosition(); + if (reconcileFrameId !== undefined) { + cancelAnimationFrame(reconcileFrameId); + } + if (secondReconcileFrameId !== undefined) { + cancelAnimationFrame(secondReconcileFrameId); + } + reconcileFrameId = requestAnimationFrame(() => { + reconcileFrameId = undefined; + notifyScrollPosition(); + secondReconcileFrameId = requestAnimationFrame(() => { + secondReconcileFrameId = undefined; + notifyScrollPosition(); + }); + }); + }; + const cancelRestore = () => { if (!isRestored()) { cancelled = true; + restoreGeneration += 1; + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + frameId = undefined; + } + if (settleTimer !== undefined) { + clearTimeout(settleTimer); + settleTimer = undefined; + } // 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); + // The virtualizer listens to native scroll events. A user event can + // cancel restoration before the browser changes scrollTop, leaving + // its previous (restored) range mounted at the top of the container. + // Re-notify it now and for the next two frames so it reconciles with + // the actual scroll position after pending layout work settles. + reconcileVirtualizerPosition(); } }; window.addEventListener("pointerdown", cancelRestore, { passive: true }); @@ -238,6 +283,10 @@ export function useScrollRestoration( restoreGeneration += 1; if (frameId !== undefined) cancelAnimationFrame(frameId); if (settleTimer !== undefined) clearTimeout(settleTimer); + if (reconcileFrameId !== undefined) cancelAnimationFrame(reconcileFrameId); + if (secondReconcileFrameId !== undefined) { + cancelAnimationFrame(secondReconcileFrameId); + } savePosition(); }); From de9418d15863531f8c0d3bd5e5face005532f5de Mon Sep 17 00:00:00 2001 From: hmjn Date: Mon, 3 Aug 2026 22:47:33 +0900 Subject: [PATCH 06/11] fix(ui): resync virtual grid after result updates --- packages/ui/src/source-media-grid.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/source-media-grid.tsx b/packages/ui/src/source-media-grid.tsx index ef7c3781..30da2bf2 100644 --- a/packages/ui/src/source-media-grid.tsx +++ b/packages/ui/src/source-media-grid.tsx @@ -348,7 +348,21 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { // without requiring a user scroll event. scrollElement(); scrollMargin(); - mediaRowVirtualizer().measure(); + const virtualizer = mediaRowVirtualizer(); + virtualizer.measure(); + const element = scrollElement(); + if ( + props.scrollMode === "element" && + element && + virtualizer.scrollOffset !== null && + virtualizer.scrollOffset !== element.scrollTop + ) { + // Query updates can clamp the real scroll position while the + // virtualizer still holds the previous offset. Reconcile from the + // element before rendering the next range instead of leaving rows + // mounted outside the viewport until another user scroll event. + virtualizer.scrollToOffset(element.scrollTop); + } }); // Virtual scroll-based load more: trigger when user scrolls near the end From 4050e4bebd5c0d4231b83e3cd1a049db1025c382 Mon Sep 17 00:00:00 2001 From: hmjn Date: Mon, 3 Aug 2026 22:56:19 +0900 Subject: [PATCH 07/11] fix(ui): notify element virtualizer after grid updates --- packages/ui/src/source-media-grid.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ui/src/source-media-grid.tsx b/packages/ui/src/source-media-grid.tsx index 30da2bf2..de2d3e7b 100644 --- a/packages/ui/src/source-media-grid.tsx +++ b/packages/ui/src/source-media-grid.tsx @@ -363,6 +363,13 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { // mounted outside the viewport until another user scroll event. virtualizer.scrollToOffset(element.scrollTop); } + if (props.scrollMode === "element" && element) { + // Element scroll containers do not consistently emit a native scroll + // event when their content is replaced or clamped. Window scrolling in + // the v1 screen gets that notification from the browser automatically; + // keep the v2 virtualizer in the same state after every grid update. + element.dispatchEvent(new Event("scroll")); + } }); // Virtual scroll-based load more: trigger when user scrolls near the end From 53c720470c939b8ead92d08b3cf5f62a33c83e88 Mon Sep 17 00:00:00 2001 From: hmjn Date: Mon, 3 Aug 2026 23:44:08 +0900 Subject: [PATCH 08/11] fix(ui): prevent virtual grid scroll reset --- .../tests/e2e/ui-components.gallery.spec.ts | 52 ++++++ apps/server/src/tests/e2e/ui-gallery/src.tsx | 168 +++++++++++------- packages/ui/src/source-media-grid.tsx | 17 +- 3 files changed, 161 insertions(+), 76 deletions(-) diff --git a/apps/server/src/tests/e2e/ui-components.gallery.spec.ts b/apps/server/src/tests/e2e/ui-components.gallery.spec.ts index 69f9901a..88ccb61c 100644 --- a/apps/server/src/tests/e2e/ui-components.gallery.spec.ts +++ b/apps/server/src/tests/e2e/ui-components.gallery.spec.ts @@ -276,6 +276,58 @@ test("virtual media grid stays populated after restoring a deep scroll position" await expectVisibleImagesLoaded(scroller); }); +test("virtual media grid preserves offset after a route-style remount and page update", async ({ + page, +}) => { + await page.setViewportSize({ width: 1_280, height: 720 }); + await page.goto(`${getGalleryUrl()}/?virtual-grid=1&remount-grid=1`); + + const scroller = page.getByTestId("virtual-grid-scroller"); + await expect(scroller.locator("[data-media-id]").first()).toBeVisible(); + await scroller.evaluate(async (element) => { + element.scrollTop = 1_500; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + }); + const beforeRemount = await scroller.evaluate((element) => element.scrollTop); + expect(beforeRemount).toBeGreaterThan(500); + + await page.getByTestId("remount-virtual-grid").click(); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(beforeRemount - 2); + await page.evaluate(() => + window.dispatchEvent(new WheelEvent("wheel", { deltaY: 1 })), + ); + const restoredTop = await scroller.evaluate((element) => element.scrollTop); + const heightBeforeAppend = await scroller.evaluate( + (element) => element.scrollHeight, + ); + const nextTop = restoredTop + 1_800; + + await page.evaluate((targetTop) => { + const element = document.querySelector( + "[data-testid=virtual-grid-scroller]", + ); + if (!element) throw new Error("Virtual grid scroller was not found"); + element.scrollTop = 0; + document + .querySelector("[data-testid=append-virtual-grid-page]") + ?.click(); + requestAnimationFrame(() => { + element.scrollTop = targetTop; + }); + }, nextTop); + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight)) + .toBeGreaterThan(heightBeforeAppend); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(restoredTop + 100); + await expectVisibleImagesLoaded(scroller); +}); + test.describe("high-density virtual media grid", () => { test.use({ deviceScaleFactor: 2 }); diff --git a/apps/server/src/tests/e2e/ui-gallery/src.tsx b/apps/server/src/tests/e2e/ui-gallery/src.tsx index f9ceef65..526404b5 100644 --- a/apps/server/src/tests/e2e/ui-gallery/src.tsx +++ b/apps/server/src/tests/e2e/ui-gallery/src.tsx @@ -101,7 +101,7 @@ import { } from "@solid-imager/ui/text-field"; import { ThumbnailImage } from "@solid-imager/ui/thumbnail-image"; import { Toaster, toast } from "@solid-imager/ui/toast"; -import { createSignal } from "solid-js"; +import { createSignal, Show } from "solid-js"; import { render } from "solid-js/web"; import "../../../app.css"; @@ -133,17 +133,20 @@ const VIRTUAL_GRID_MEDIA = Array.from( ); function VirtualGridGallery() { - const restoreGrid = new URLSearchParams(window.location.search).has( - "restore-grid", - ); + const params = new URLSearchParams(window.location.search); + const restoreGrid = params.has("restore-grid"); + const remountGrid = params.has("remount-grid"); + const pagedGrid = restoreGrid || remountGrid; const [loadedCount, setLoadedCount] = createSignal( - restoreGrid ? 200 : VIRTUAL_GRID_ITEM_COUNT, + pagedGrid ? 200 : VIRTUAL_GRID_ITEM_COUNT, ); const [isFetchingNextPage, setIsFetchingNextPage] = createSignal(false); + const [savedScrollTop, setSavedScrollTop] = createSignal(0); + const [showGrid, setShowGrid] = createSignal(true); const mediaResults = () => VIRTUAL_GRID_MEDIA.slice(0, loadedCount()); const fetchNextPage = async () => { if ( - !restoreGrid || + !pagedGrid || isFetchingNextPage() || loadedCount() >= VIRTUAL_GRID_ITEM_COUNT ) { @@ -154,71 +157,112 @@ function VirtualGridGallery() { 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]", - }); + const saveScrollPosition = () => { + const scroller = document.querySelector( + "[data-testid=virtual-grid-scroller]", + ); + if (scroller) setSavedScrollTop(scroller.scrollTop); + }; + const remount = () => { + saveScrollPosition(); + setShowGrid(false); + setTimeout(() => setShowGrid(true), 25); + }; + const VirtualGridContent = () => { + useScrollRestoration({ + restoreKey: () => "virtual-grid", + getPosition: () => savedScrollTop() || (restoreGrid ? 20_000 : 0), + setPosition: (_key, position) => setSavedScrollTop(position), + isReady: () => mediaResults().length > 0, + hasNextPage: () => loadedCount() < VIRTUAL_GRID_ITEM_COUNT, + isFetchingNextPage, + fetchNextPage, + scrollContainerSelector: "[data-media-scroll]", + }); + + return ( + 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. + + + `/virtual-thumbnail/${media.id}-256.webp 256w, /virtual-thumbnail/${media.id}-512.webp 512w`, + getUrl: () => `/virtual-thumbnail/${media.id}-512.webp`, + }} + width={512} + /> + + )} + scrollMode="element" + setLoadMoreRef={() => undefined} + showResultCount={false} + state={() => ({ + data: mediaResults(), + error: undefined, + fetchState: "idle", + phase: "data", + })} + totalCount={VIRTUAL_GRID_ITEM_COUNT} + /> + ); + }; return (

Virtual media grid performance fixture

+ +
+ + +
+
- 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. - - - `/virtual-thumbnail/${media.id}-256.webp 256w, /virtual-thumbnail/${media.id}-512.webp 512w`, - getUrl: () => `/virtual-thumbnail/${media.id}-512.webp`, - }} - width={512} - /> - - )} - scrollMode="element" - setLoadMoreRef={() => undefined} - showResultCount={false} - state={() => ({ - data: mediaResults(), - error: undefined, - fetchState: "idle", - phase: "data", - })} - totalCount={VIRTUAL_GRID_ITEM_COUNT} - /> + + +
); diff --git a/packages/ui/src/source-media-grid.tsx b/packages/ui/src/source-media-grid.tsx index de2d3e7b..431a5963 100644 --- a/packages/ui/src/source-media-grid.tsx +++ b/packages/ui/src/source-media-grid.tsx @@ -351,23 +351,12 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { const virtualizer = mediaRowVirtualizer(); virtualizer.measure(); const element = scrollElement(); - if ( - props.scrollMode === "element" && - element && - virtualizer.scrollOffset !== null && - virtualizer.scrollOffset !== element.scrollTop - ) { - // Query updates can clamp the real scroll position while the - // virtualizer still holds the previous offset. Reconcile from the - // element before rendering the next range instead of leaving rows - // mounted outside the viewport until another user scroll event. - virtualizer.scrollToOffset(element.scrollTop); - } if (props.scrollMode === "element" && element) { // Element scroll containers do not consistently emit a native scroll // event when their content is replaced or clamped. Window scrolling in - // the v1 screen gets that notification from the browser automatically; - // keep the v2 virtualizer in the same state after every grid update. + // the v1 screen gets that notification from the browser automatically. + // Dispatching lets the virtualizer read the element's current offset + // without imperatively writing a possibly stale offset back to it. element.dispatchEvent(new Event("scroll")); } }); From 7d8b8ab73b547ed251fa82f4e73dd39bb685dbe9 Mon Sep 17 00:00:00 2001 From: hmjn Date: Tue, 4 Aug 2026 01:08:02 +0900 Subject: [PATCH 09/11] fix(ui): avoid suspense reset during paginated scroll --- .../tests/e2e/ui-components.gallery.spec.ts | 35 ++++++++++++++ apps/server/src/tests/e2e/ui-gallery/src.tsx | 46 +++++++++++++++---- packages/ui/src/hooks/use-search-page.ts | 41 ++++++++++++++--- .../ui/src/hooks/use-source-media-page.ts | 32 +++++++++++-- packages/ui/src/screens/search-screen.tsx | 2 +- .../ui/src/screens/source-media-screen.tsx | 10 ++-- packages/ui/src/screens/v2-search-screen.tsx | 4 +- .../ui/src/screens/v2-source-media-screen.tsx | 8 ++-- 8 files changed, 145 insertions(+), 33 deletions(-) diff --git a/apps/server/src/tests/e2e/ui-components.gallery.spec.ts b/apps/server/src/tests/e2e/ui-components.gallery.spec.ts index 88ccb61c..a87b545b 100644 --- a/apps/server/src/tests/e2e/ui-components.gallery.spec.ts +++ b/apps/server/src/tests/e2e/ui-components.gallery.spec.ts @@ -328,6 +328,41 @@ test("virtual media grid preserves offset after a route-style remount and page u await expectVisibleImagesLoaded(scroller); }); +test("virtual media grid preserves offset while fast scrolling starts a page fetch", async ({ + page, +}) => { + await page.setViewportSize({ width: 1_280, height: 720 }); + await page.goto(`${getGalleryUrl()}/?virtual-grid=1&remount-grid=1`); + + const scroller = page.getByTestId("virtual-grid-scroller"); + await expect(scroller.locator("[data-media-id]").first()).toBeVisible(); + await scroller.evaluate(async (element) => { + element.scrollTop = 1_500; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + }); + + await page.getByTestId("remount-virtual-grid").click(); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(1_000); + await page.evaluate(() => + window.dispatchEvent(new WheelEvent("wheel", { deltaY: 1 })), + ); + + const restoredTop = await scroller.evaluate((element) => element.scrollTop); + await scroller.evaluate((element) => { + element.scrollTop = element.scrollHeight - element.clientHeight - 1; + }); + + await expect(page.getByText("読み込み中...", { exact: true })).toBeVisible(); + await expect(page.getByText("読み込み中...", { exact: true })).toHaveCount(0); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(restoredTop + 100); +}); + test.describe("high-density virtual media grid", () => { test.use({ deviceScaleFactor: 2 }); diff --git a/apps/server/src/tests/e2e/ui-gallery/src.tsx b/apps/server/src/tests/e2e/ui-gallery/src.tsx index 526404b5..31cc0a04 100644 --- a/apps/server/src/tests/e2e/ui-gallery/src.tsx +++ b/apps/server/src/tests/e2e/ui-gallery/src.tsx @@ -10,6 +10,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@solid-imager/ui/alert-dialog"; +import { QueryStatus } from "@solid-imager/ui/async-state"; import { Badge } from "@solid-imager/ui/badge"; import { Button, buttonVariants } from "@solid-imager/ui/button"; import { @@ -101,7 +102,7 @@ import { } from "@solid-imager/ui/text-field"; import { ThumbnailImage } from "@solid-imager/ui/thumbnail-image"; import { Toaster, toast } from "@solid-imager/ui/toast"; -import { createSignal, Show } from "solid-js"; +import { createEffect, createSignal, onCleanup, Show } from "solid-js"; import { render } from "solid-js/web"; import "../../../app.css"; @@ -137,12 +138,14 @@ function VirtualGridGallery() { const restoreGrid = params.has("restore-grid"); const remountGrid = params.has("remount-grid"); const pagedGrid = restoreGrid || remountGrid; + const fetchDelayMs = remountGrid ? 100 : 10; const [loadedCount, setLoadedCount] = createSignal( pagedGrid ? 200 : VIRTUAL_GRID_ITEM_COUNT, ); const [isFetchingNextPage, setIsFetchingNextPage] = createSignal(false); const [savedScrollTop, setSavedScrollTop] = createSignal(0); const [showGrid, setShowGrid] = createSignal(true); + const [loadMoreRef, setLoadMoreRef] = createSignal(); const mediaResults = () => VIRTUAL_GRID_MEDIA.slice(0, loadedCount()); const fetchNextPage = async () => { if ( @@ -153,10 +156,23 @@ function VirtualGridGallery() { return; } setIsFetchingNextPage(true); - await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, fetchDelayMs)); setLoadedCount((count) => Math.min(count + 200, VIRTUAL_GRID_ITEM_COUNT)); setIsFetchingNextPage(false); }; + createEffect(() => { + const element = loadMoreRef(); + const hasNextPage = loadedCount() < VIRTUAL_GRID_ITEM_COUNT; + if (!element || !hasNextPage) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) void fetchNextPage(); + }, + { threshold: 0.5, rootMargin: "2400px" }, + ); + observer.observe(element); + onCleanup(() => observer.disconnect()); + }); const saveScrollPosition = () => { const scroller = document.querySelector( "[data-testid=virtual-grid-scroller]", @@ -217,7 +233,7 @@ function VirtualGridGallery() { )} scrollMode="element" - setLoadMoreRef={() => undefined} + setLoadMoreRef={setLoadMoreRef} showResultCount={false} state={() => ({ data: mediaResults(), @@ -231,7 +247,7 @@ function VirtualGridGallery() { }; return ( -
+

Virtual media grid performance fixture

@@ -255,14 +271,28 @@ function VirtualGridGallery() {
+
+
+ +
- - - +
+
+ + + +
+
); diff --git a/packages/ui/src/hooks/use-search-page.ts b/packages/ui/src/hooks/use-search-page.ts index e99ca5be..2574be0f 100644 --- a/packages/ui/src/hooks/use-search-page.ts +++ b/packages/ui/src/hooks/use-search-page.ts @@ -9,7 +9,12 @@ import type { import type { Project } from "@solid-imager/core/domain/projects/schemas"; import type { SafeMediaSource } from "@solid-imager/core/domain/sources/schemas"; import type { TagResponse } from "@solid-imager/core/domain/tags/schemas"; -import { createInfiniteQuery, createQuery } from "@tanstack/solid-query"; +import { + createInfiniteQuery, + createQuery, + type InfiniteData, + useQueryClient, +} from "@tanstack/solid-query"; import { type Accessor, createEffect, @@ -85,6 +90,8 @@ export interface UseSearchPageResult { typeof createInfiniteQuery >; searchResults: () => MediaSearchResponse["media"]; + hasData: () => boolean; + totalCount: () => number | undefined; contentState: () => QueryUiState; filterStates: { tags: () => QueryUiState; @@ -157,7 +164,7 @@ export function useSearchPage( JSON.stringify(getSearchCondition() ?? null), ); - const searchResultQuery = createInfiniteQuery(() => + const searchResultQueryOptions = createMemo(() => buildSearchResultsQueryOptions({ mode: mode(), sourceId: selectedSource() || undefined, @@ -174,10 +181,31 @@ export function useSearchPage( gcTime, }), ); + const searchResultQuery = createInfiniteQuery(searchResultQueryOptions); + const queryClient = useQueryClient(); + const [searchResultData, setSearchResultData] = createSignal< + InfiniteData | undefined + >(); + + createEffect(() => { + // Solid Query exposes `data` as a resource-backed accessor. During an + // infinite-page fetch it can suspend even when previous pages are cached; + // keep the rendered result in a regular signal so the collection DOM stays + // mounted while the next page is loading. + const queryKey = searchResultQueryOptions().queryKey; + searchResultQuery.dataUpdatedAt; + const cachedData = + queryClient.getQueryData>(queryKey); + if (cachedData !== undefined) { + setSearchResultData(cachedData); + } else if (!searchResultQuery.isPlaceholderData) { + setSearchResultData(undefined); + } + }); const searchResults = createMemo(() => { const seen = new Set(); - return (searchResultQuery.data?.pages.flatMap((p) => p.media) || []).filter( + return (searchResultData()?.pages.flatMap((p) => p.media) || []).filter( (m) => { if (seen.has(m.id)) { return false; @@ -190,7 +218,7 @@ export function useSearchPage( const contentState = () => toQueryUiState( { - data: searchResultQuery.data ? searchResults() : undefined, + data: searchResultData() ? searchResults() : undefined, error: searchResultQuery.error, status: searchResultQuery.status, fetchStatus: searchResultQuery.fetchStatus, @@ -254,8 +282,7 @@ export function useSearchPage( void key; setScrollY(position); }, - isReady: () => - Boolean(searchResultQuery.data) && !searchResultQuery.isLoading, + isReady: () => Boolean(searchResultData()) && !searchResultQuery.isLoading, hasNextPage: () => searchResultQuery.hasNextPage, isFetchingNextPage: () => searchResultQuery.isFetchingNextPage, fetchNextPage: () => searchResultQuery.fetchNextPage(), @@ -306,6 +333,8 @@ export function useSearchPage( return { searchResultQuery, searchResults, + hasData: () => searchResultData() !== undefined, + totalCount: () => searchResultData()?.pages[0]?.total, contentState, filterStates: { tags: () => arrayState(tags), diff --git a/packages/ui/src/hooks/use-source-media-page.ts b/packages/ui/src/hooks/use-source-media-page.ts index c8d09695..37f72600 100644 --- a/packages/ui/src/hooks/use-source-media-page.ts +++ b/packages/ui/src/hooks/use-source-media-page.ts @@ -177,6 +177,8 @@ export type UseSourceMediaPageResult = { mediaSourceId: () => string | undefined; mediaQuery: ReturnType>; mediaResults: () => MediaSearchResponse["media"]; + hasData: () => boolean; + totalCount: () => number | undefined; contentState: () => QueryUiState; filterData: () => SourceMediaPageFilterData; filterStates: () => SourceMediaPageQueryStates; @@ -255,7 +257,7 @@ export function useSourceMediaPage( JSON.stringify(getSearchCondition() ?? null), ); - const mediaQuery = createInfiniteQuery(() => + const mediaQueryOptions = createMemo(() => buildSourceMediaResultsQueryOptions({ sourceId: id(), condition: getSearchCondition(), @@ -267,11 +269,29 @@ export function useSourceMediaPage( enabled: !isServer && isSearchStateRestored() && !!id(), }), ); + const mediaQuery = createInfiniteQuery(mediaQueryOptions); + const [mediaQueryData, setMediaQueryData] = createSignal< + InfiniteData | undefined + >(); + + createEffect(() => { + // Keep paginated results outside Solid Query's resource-backed `data` + // accessor. A page fetch must not suspend and remount the media grid. + const queryKey = mediaQueryOptions().queryKey; + mediaQuery.dataUpdatedAt; + const cachedData = + queryClient.getQueryData>(queryKey); + if (cachedData !== undefined) { + setMediaQueryData(cachedData); + } else if (!mediaQuery.isPlaceholderData) { + setMediaQueryData(undefined); + } + }); // --- Deduplicated results --- const mediaResults = createMemo(() => { const seen = new Set(); - return (mediaQuery.data?.pages.flatMap((page) => page.media) || []).filter( + return (mediaQueryData()?.pages.flatMap((page) => page.media) || []).filter( (media) => { if (seen.has(media.id)) { return false; @@ -284,7 +304,7 @@ export function useSourceMediaPage( const contentState = () => toQueryUiState( { - data: mediaQuery.data ? mediaResults() : undefined, + data: mediaQueryData() ? mediaResults() : undefined, error: mediaQuery.error, status: mediaQuery.status, fetchStatus: mediaQuery.fetchStatus, @@ -304,7 +324,7 @@ export function useSourceMediaPage( restoreKey: id, getPosition: (sourceId) => getScrollPosition(sourceId), setPosition: (sourceId, position) => setScrollPosition(sourceId, position), - isReady: () => Boolean(mediaQuery.data) && !mediaQuery.isLoading, + isReady: () => Boolean(mediaQueryData()) && !mediaQuery.isLoading, hasNextPage: () => mediaQuery.hasNextPage, isFetchingNextPage: () => mediaQuery.isFetchingNextPage, fetchNextPage: () => mediaQuery.fetchNextPage(), @@ -908,7 +928,7 @@ export function useSourceMediaPage( }; const handleSyncLoadedMedia = async () => { - const allPages = mediaQuery.data?.pages; + const allPages = mediaQueryData()?.pages; if (!allPages || isSyncingMedia()) { return; } @@ -946,6 +966,8 @@ export function useSourceMediaPage( mediaSourceId: id, mediaQuery, mediaResults, + hasData: () => mediaQueryData() !== undefined, + totalCount: () => mediaQueryData()?.pages[0]?.total, contentState, filterData, filterStates: queryStates, diff --git a/packages/ui/src/screens/search-screen.tsx b/packages/ui/src/screens/search-screen.tsx index 7b318256..4216b99d 100644 --- a/packages/ui/src/screens/search-screen.tsx +++ b/packages/ui/src/screens/search-screen.tsx @@ -161,7 +161,7 @@ export function SearchScreen(props: SearchScreenProps) { showEmptyState showResultCount state={page().contentState} - totalCount={page().searchResultQuery.data?.pages[0]?.total} + totalCount={page().totalCount()} />
diff --git a/packages/ui/src/screens/source-media-screen.tsx b/packages/ui/src/screens/source-media-screen.tsx index f0c772ad..a8e58a50 100644 --- a/packages/ui/src/screens/source-media-screen.tsx +++ b/packages/ui/src/screens/source-media-screen.tsx @@ -126,9 +126,9 @@ export function SourceMediaScreen(props: SourceMediaScreenProps) {

{props.mediaSourceName?.() ?? "メディア一覧"}

- +

- {page().mediaQuery.data?.pages[0]?.total} 件の結果 + {page().totalCount()} 件の結果

@@ -144,9 +144,7 @@ export function SourceMediaScreen(props: SourceMediaScreenProps) {
diff --git a/packages/ui/src/screens/v2-search-screen.tsx b/packages/ui/src/screens/v2-search-screen.tsx index a2ae6075..ab457375 100644 --- a/packages/ui/src/screens/v2-search-screen.tsx +++ b/packages/ui/src/screens/v2-search-screen.tsx @@ -37,7 +37,7 @@ export function V2SearchScreen(props: SearchScreenProps) {
diff --git a/packages/ui/src/screens/v2-source-media-screen.tsx b/packages/ui/src/screens/v2-source-media-screen.tsx index 31f4c970..ba9a8e62 100644 --- a/packages/ui/src/screens/v2-source-media-screen.tsx +++ b/packages/ui/src/screens/v2-source-media-screen.tsx @@ -51,9 +51,7 @@ export function V2SourceMediaScreen(props: SourceMediaScreenProps) {
From 56a5b848990c5f426d036373085671a699d5d5a8 Mon Sep 17 00:00:00 2001 From: hmjn Date: Thu, 6 Aug 2026 01:24:22 +0900 Subject: [PATCH 10/11] fix(ui): stabilize v2 media scroll restoration --- apps/server/nitro.config.ts | 31 ++- apps/server/scripts/isolated-runtime.ts | 61 ++++- .../imports/pending-downloads-indicator.tsx | 50 +++- .../server/src/components/v2/v2-app-shell.tsx | 13 +- .../src/hooks/use-media-source-events.ts | 4 +- .../sources/$mediaSourceId/$mediaId/index.tsx | 2 +- .../tests/e2e/v2-scroll-restoration.spec.ts | 237 ++++++++++++++++++ .../use-current-search-persistence.test.ts | 7 + .../hooks/use-current-search-persistence.ts | 44 ++-- packages/ui/src/hooks/use-search-page.ts | 124 ++++++--- packages/ui/src/screens/search-screen.tsx | 2 +- packages/ui/src/screens/v2-search-screen.tsx | 2 +- packages/ui/src/source-media-grid.tsx | 17 +- packages/ui/src/v2/search-toolbar.tsx | 1 - 14 files changed, 514 insertions(+), 81 deletions(-) create mode 100644 apps/server/src/tests/e2e/v2-scroll-restoration.spec.ts diff --git a/apps/server/nitro.config.ts b/apps/server/nitro.config.ts index e32fe49c..3f77da01 100644 --- a/apps/server/nitro.config.ts +++ b/apps/server/nitro.config.ts @@ -28,21 +28,38 @@ export default defineNitroConfig({ fs.existsSync(pgliteLocalPath) ? pgliteLocalPath : pgliteRootPath, ); const pgliteDistPath = path.join(pglitePkgPath, "dist"); + const pgvectorLocalPath = path.resolve( + __dirname, + "node_modules/@electric-sql/pglite-pgvector/package.json", + ); + const pgvectorRootPath = path.resolve( + __dirname, + "../../node_modules/@electric-sql/pglite-pgvector/package.json", + ); + const pgvectorPkgPath = path.dirname( + fs.existsSync(pgvectorLocalPath) ? pgvectorLocalPath : pgvectorRootPath, + ); - const assetsToCopy = ["pglite.data", "pglite.wasm"]; + const assetsToCopy = [ + { name: "pglite.data", source: path.join(pgliteDistPath, "pglite.data") }, + { name: "pglite.wasm", source: path.join(pgliteDistPath, "pglite.wasm") }, + { + name: "vector.tar.gz", + source: path.join(pgvectorPkgPath, "dist/vector.tar.gz"), + }, + ]; for (const asset of assetsToCopy) { - const source = path.join(pgliteDistPath, asset); - const destination = path.join(libsDir, asset); + const destination = path.join(libsDir, asset.name); - if (fs.existsSync(source)) { + if (fs.existsSync(asset.source)) { if (!fs.existsSync(libsDir)) { fs.mkdirSync(libsDir, { recursive: true }); } - fs.copyFileSync(source, destination); - console.log(`[Nitro] Successfully copied ${asset} to ${destination}`); + fs.copyFileSync(asset.source, destination); + console.log(`[Nitro] Successfully copied ${asset.name} to ${destination}`); } else { - console.warn(`[Nitro] Warning: ${asset} not found at ${source}`); + console.warn(`[Nitro] Warning: ${asset.name} not found at ${asset.source}`); } } diff --git a/apps/server/scripts/isolated-runtime.ts b/apps/server/scripts/isolated-runtime.ts index d023d6a2..9775a0c3 100644 --- a/apps/server/scripts/isolated-runtime.ts +++ b/apps/server/scripts/isolated-runtime.ts @@ -1,4 +1,5 @@ -import { mkdir, rm, stat, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { copyFile, mkdir, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { defaultAppConfig } from "@solid-imager/core/domain/config/config-schema"; import { mediaGenerationInfo, medias, mediaSources } from "@solid-imager/db/schema"; @@ -16,6 +17,7 @@ import { } from "../src/tests/e2e/support/fixture"; const appRoot = path.resolve(import.meta.dir, ".."); +const E2E_PAGINATED_MEDIA_COUNT = 600; export type IsolatedRuntime = { routeTreePath: string; @@ -42,8 +44,10 @@ function createImageSvg(accentColor: string, backgroundColor: string): Buffer { async function seedMediaFixtures(runtimeDir: string): Promise { const mediaDir = path.join(runtimeDir, "media"); const thumbnailDir = path.join(runtimeDir, "thumbnails", E2E_SOURCE_ID); + const smallThumbnailDir = path.join(thumbnailDir, "256"); await mkdir(mediaDir, { recursive: true }); await mkdir(thumbnailDir, { recursive: true }); + await mkdir(smallThumbnailDir, { recursive: true }); const primaryPath = path.join(mediaDir, E2E_PRIMARY_FILE_NAME); const similarPath = path.join(mediaDir, E2E_SIMILAR_FILE_NAME); @@ -64,6 +68,39 @@ async function seedMediaFixtures(runtimeDir: string): Promise { ]); const [primaryStats, similarStats] = await Promise.all([stat(primaryPath), stat(similarPath)]); + const paginatedMedia = Array.from({ length: E2E_PAGINATED_MEDIA_COUNT }, (_, index) => ({ + id: randomUUID(), + fileName: `e2e-scroll-${String(index + 1).padStart(4, "0")}.png`, + })); + const primaryThumbnailPath = path.join( + thumbnailDir, + `${E2E_PRIMARY_MEDIA_ID}.webp`, + ); + const similarThumbnailPath = path.join( + thumbnailDir, + `${E2E_SIMILAR_MEDIA_ID}.webp`, + ); + await Promise.all( + [ + copyFile( + primaryThumbnailPath, + path.join(smallThumbnailDir, `${E2E_PRIMARY_MEDIA_ID}.webp`), + ), + copyFile( + similarThumbnailPath, + path.join(smallThumbnailDir, `${E2E_SIMILAR_MEDIA_ID}.webp`), + ), + ...paginatedMedia.flatMap(({ id, fileName }) => [ + copyFile(primaryPath, path.join(mediaDir, fileName)), + copyFile(primaryThumbnailPath, path.join(thumbnailDir, `${id}.webp`)), + copyFile( + primaryThumbnailPath, + path.join(smallThumbnailDir, `${id}.webp`), + ), + ]), + ], + ); + const pgliteDir = path.join(runtimeDir, "pglite"); const client = createPglite(pgliteDir); const db = drizzle(client); @@ -111,6 +148,24 @@ async function seedMediaFixtures(runtimeDir: string): Promise { indexedAt: seededAt, status: "active", }, + ...paginatedMedia.map(({ id, fileName }, index) => { + const createdAt = new Date(seededAt.getTime() - (index + 2) * 1000); + return { + id, + mediaSourceId: E2E_SOURCE_ID, + filePath: fileName, + fileName, + mediaType: "image" as const, + width: 256, + height: 256, + fileSize: primaryStats.size, + description: "Paginated browser scroll fixture media", + createdAt, + modifiedAt: createdAt, + indexedAt: seededAt, + status: "active" as const, + }; + }), ]); await db.insert(mediaGenerationInfo).values([ { @@ -121,6 +176,10 @@ async function seedMediaFixtures(runtimeDir: string): Promise { mediaId: E2E_SIMILAR_MEDIA_ID, metadata: { fixture: "e2e-similar" }, }, + ...paginatedMedia.map(({ id }) => ({ + mediaId: id, + metadata: { fixture: "e2e-scroll" }, + })), ]); } finally { await client.close(); diff --git a/apps/server/src/components/imports/pending-downloads-indicator.tsx b/apps/server/src/components/imports/pending-downloads-indicator.tsx index de84a1ef..8fa28a2f 100644 --- a/apps/server/src/components/imports/pending-downloads-indicator.tsx +++ b/apps/server/src/components/imports/pending-downloads-indicator.tsx @@ -1,11 +1,54 @@ import { subscribeToEventStream } from "@solid-imager/ui/event-stream"; import { + type ImportEventConnectedHandler, + type ImportEventHandler, type PendingDownloadsIndicatorProps, PendingDownloadsIndicator as SharedPendingDownloadsIndicator, } from "@solid-imager/ui/pending-downloads-indicator"; import { orpc } from "~/infrastructure/api-clients/orpc-client"; import { fetchMediaSources } from "~/infrastructure/api-clients/sources-api"; +type ImportSubscriber = { + handler: ImportEventHandler; + onConnected?: ImportEventConnectedHandler; +}; + +const importSubscribers = new Set(); +let cleanupImportStream: (() => void) | undefined; + +function subscribeSharedImportEvents( + handler: ImportEventHandler, + onConnected?: ImportEventConnectedHandler, +): () => void { + const subscriber = { handler, onConnected }; + importSubscribers.add(subscriber); + + if (!cleanupImportStream) { + cleanupImportStream = subscribeToEventStream( + (signal) => orpc.imports.events(undefined, { signal }), + (event) => { + for (const current of importSubscribers) { + void current.handler(event); + } + }, + undefined, + async () => { + for (const current of importSubscribers) { + await current.onConnected?.(); + } + }, + ); + } + + return () => { + importSubscribers.delete(subscriber); + if (importSubscribers.size === 0) { + cleanupImportStream?.(); + cleanupImportStream = undefined; + } + }; +} + export function PendingDownloadsIndicator( displayProps: { compact?: boolean; variant?: "default" | "v2" } = {}, ) { @@ -37,12 +80,7 @@ export function PendingDownloadsIndicator( return { success: result.success }; }, subscribeImportEvents: (handler, onConnected) => { - return subscribeToEventStream( - (signal) => orpc.imports.events(undefined, { signal }), - handler, - undefined, - onConnected, - ); + return subscribeSharedImportEvents(handler, onConnected); }, }; diff --git a/apps/server/src/components/v2/v2-app-shell.tsx b/apps/server/src/components/v2/v2-app-shell.tsx index 8d86e20e..cfc49814 100644 --- a/apps/server/src/components/v2/v2-app-shell.tsx +++ b/apps/server/src/components/v2/v2-app-shell.tsx @@ -16,7 +16,6 @@ import { DialogHeader, DialogTitle, } from "@solid-imager/ui/dialog"; -import { subscribeToEventStream } from "@solid-imager/ui/event-stream"; import type { RawEventHandler } from "@solid-imager/ui/hooks/use-sources-events"; import { useSourcesPage } from "@solid-imager/ui/hooks/use-sources-page"; import { @@ -49,7 +48,7 @@ import { Link, useLocation } from "@tanstack/solid-router"; import type { JSX, ParentProps } from "solid-js"; import { createSignal, For, Show } from "solid-js"; import { PendingDownloadsIndicator } from "~/components/imports/pending-downloads-indicator"; -import { orpc } from "~/infrastructure/api-clients/orpc-client"; +import { createServerTransport } from "~/hooks/use-media-source-events"; import { mediaSourcesQueryOptions } from "~/infrastructure/api-clients/queries"; import { createMediaSource, @@ -80,13 +79,6 @@ const V2_NAVIGATION_ITEMS = [ { icon: Settings, label: "Settings", to: "/v2/config" }, ] as const; -function registerSourceEvents(handler: RawEventHandler): () => void { - return subscribeToEventStream( - (signal) => orpc.sources.events({ id: "*" }, { signal }), - handler, - ); -} - function sourceTypeLabel(source: SafeMediaSource): string { return source.type === "local" ? "Local" : source.type.toUpperCase(); } @@ -357,6 +349,9 @@ function V2Sidebar(props: V2SidebarProps) { export function V2AppShell(props: V2AppShellProps) { const queryClient = useQueryClient(); const mediaSources = createQuery(mediaSourcesQueryOptions); + const sourceEventTransport = createServerTransport(() => "*"); + const registerSourceEvents = (handler: RawEventHandler) => + sourceEventTransport.listen(handler); const [sidebarExpanded, setSidebarExpanded] = createSignal(true); const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false); const sourceData = () => mediaSources.data ?? []; diff --git a/apps/server/src/hooks/use-media-source-events.ts b/apps/server/src/hooks/use-media-source-events.ts index aaa3c551..2534a0f5 100644 --- a/apps/server/src/hooks/use-media-source-events.ts +++ b/apps/server/src/hooks/use-media-source-events.ts @@ -32,7 +32,7 @@ type SharedSourceTransport = { idleSince: number | null; }; -const SOURCE_TRANSPORT_IDLE_TIMEOUT_MS = 30_000; +const SOURCE_TRANSPORT_IDLE_TIMEOUT_MS = 0; const MAX_IDLE_SOURCE_TRANSPORTS = 2; type SourceTransportGlobal = typeof globalThis & { @@ -79,7 +79,7 @@ export function createServerTransport( if (!id) { return false; } - if (pathname === "/search" || pathname === "/v2/search") { + if (id === "*" && (pathname === "/search" || pathname.startsWith("/v2/"))) { return true; } if (id === "*") { diff --git a/apps/server/src/routes/v2/sources/$mediaSourceId/$mediaId/index.tsx b/apps/server/src/routes/v2/sources/$mediaSourceId/$mediaId/index.tsx index c23a3419..937b38b9 100644 --- a/apps/server/src/routes/v2/sources/$mediaSourceId/$mediaId/index.tsx +++ b/apps/server/src/routes/v2/sources/$mediaSourceId/$mediaId/index.tsx @@ -55,7 +55,7 @@ function MediaDetailHeader(props: { returnPath.startsWith("/v2/sources/")) ) { sessionStorage.removeItem("v2:media-return"); - void navigate({ to: returnPath }); + window.history.back(); return; } void navigate({ diff --git a/apps/server/src/tests/e2e/v2-scroll-restoration.spec.ts b/apps/server/src/tests/e2e/v2-scroll-restoration.spec.ts new file mode 100644 index 00000000..ccb3f847 --- /dev/null +++ b/apps/server/src/tests/e2e/v2-scroll-restoration.spec.ts @@ -0,0 +1,237 @@ +import type { Page } from "@playwright/test"; +import { E2E_SOURCE_ID, E2E_SOURCE_NAME } from "./support/fixture"; +import { expect, test, waitForAppHydration } from "./support/test"; + +const searchEndpoint = "**/api/rpc/media/search**"; + +async function verifyRestoredScrollerDuringFastPageFetch( + page: Page, + entryPath: string, + heading: string, + scrollerSelector: string, + returnMethod: "browser" | "ui", +): Promise { + await page.setViewportSize({ width: 1280, height: 633 }); + + let delaySearchRequests = false; + let delayedNextPageRequestCount = 0; + await page.addInitScript(() => { + sessionStorage.removeItem("current-all"); + sessionStorage.removeItem("solid-imager-scroll-positions"); + sessionStorage.removeItem("v2:media-return"); + }); + await page.route(searchEndpoint, async (route) => { + if (delaySearchRequests && route.request().method() === "POST") { + if ((route.request().postData() ?? "").includes('"offset":200')) { + delayedNextPageRequestCount += 1; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + await route.continue(); + }); + + await page.goto(entryPath); + await waitForAppHydration(page); + await expect(page.getByText(heading, { exact: true }).last()).toBeVisible(); + + const scroller = page.locator(scrollerSelector); + await expect(scroller.locator("[data-media-id]").first()).toBeVisible(); + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight)) + .toBeGreaterThan(2000); + await scroller.hover(); + await page.mouse.wheel(0, 1500); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(1000); + await page.waitForTimeout(300); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(1000); + + const mediaId = await scroller.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + return [...element.querySelectorAll("[data-media-id]")].find( + (item) => { + const itemBounds = item.getBoundingClientRect(); + return itemBounds.bottom > bounds.top && itemBounds.top < bounds.bottom; + }, + )?.dataset.mediaId; + }); + expect(mediaId).toBeDefined(); + if (!mediaId) throw new Error("A visible media item was not found"); + const mediaFileName = await page + .locator(`[data-media-id="${mediaId}"] img`) + .getAttribute("alt"); + expect(mediaFileName).toBeTruthy(); + if (!mediaFileName) + throw new Error("The visible media item has no file name"); + + await page.locator(`[data-media-id="${mediaId}"]`).click(); + await expect(page).toHaveURL(/\/v2\/sources\/[^/]+\/[^/]+$/); + await expect( + page.getByRole("heading", { name: mediaFileName, exact: true }), + ).toBeVisible(); + + if (returnMethod === "browser") { + await page.goBack(); + } else { + await page.getByRole("button", { name: "一覧に戻る", exact: true }).click(); + } + await expect.poll(() => new URL(page.url()).pathname).toBe(entryPath); + await waitForAppHydration(page); + await expect(scroller.locator("[data-media-id]").first()).toBeVisible(); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(1000); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + + await page.evaluate((selector) => { + const main = document.querySelector("#v2-main-content"); + const initialScroller = document.querySelector(selector); + if (!main || !initialScroller) { + throw new Error("v2 media scroll containers were not found"); + } + + const probe = { + initialMain: main, + initialScroller, + removedSections: 0, + scrollTops: [] as number[], + }; + const observer = new MutationObserver((records) => { + for (const record of records) { + if (record.target !== main) continue; + probe.removedSections += [...record.removedNodes].filter( + (node) => node instanceof HTMLElement && node.tagName === "SECTION", + ).length; + } + }); + observer.observe(main, { childList: true }); + window.addEventListener( + "scroll", + (event) => { + if (event.target instanceof HTMLElement) { + probe.scrollTops.push(event.target.scrollTop); + } + }, + true, + ); + Object.assign(window, { __v2ScrollProbe: probe }); + }, scrollerSelector); + + const heightBeforeFetch = await scroller.evaluate( + (element) => element.scrollHeight, + ); + const nextPageResponsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/rpc/media/search") && + (response.request().postData() ?? "").includes('"offset":200'), + { timeout: 10_000 }, + ); + delaySearchRequests = true; + await scroller.hover(); + await page.mouse.wheel(0, 1800); + await expect.poll(() => delayedNextPageRequestCount).toBeGreaterThan(0); + await page.waitForTimeout(100); + + const probeDuringFetch = await page.evaluate((selector) => { + const probe = ( + window as Window & { + __v2ScrollProbe?: { + initialMain: Element; + initialScroller: Element; + removedSections: number; + scrollTops: number[]; + }; + } + ).__v2ScrollProbe; + const currentScroller = document.querySelector(selector); + return { + scrollEventCount: probe?.scrollTops.length ?? 0, + removedSections: probe?.removedSections ?? -1, + mainWasReplaced: + probe?.initialMain !== document.querySelector("#v2-main-content"), + scrollerWasReplaced: probe?.initialScroller !== currentScroller, + minObservedScrollTop: probe?.scrollTops.length + ? Math.min(...probe.scrollTops) + : -1, + finalScrollTop: + currentScroller instanceof HTMLElement ? currentScroller.scrollTop : -1, + }; + }, scrollerSelector); + + expect(probeDuringFetch.removedSections).toBe(0); + expect(probeDuringFetch.scrollEventCount).toBeGreaterThan(0); + expect(probeDuringFetch.mainWasReplaced).toBe(false); + expect(probeDuringFetch.scrollerWasReplaced).toBe(false); + expect(probeDuringFetch.minObservedScrollTop).toBeGreaterThan(0); + expect(probeDuringFetch.finalScrollTop).toBeGreaterThan(1000); + + const nextPageResponse = await nextPageResponsePromise; + expect(nextPageResponse.status()).toBe(200); + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight), { + timeout: 10_000, + }) + .toBeGreaterThan(heightBeforeFetch); + const probeAfterFetch = await page.evaluate((selector) => { + const probe = ( + window as Window & { + __v2ScrollProbe?: { + initialMain: Element; + initialScroller: Element; + removedSections: number; + }; + } + ).__v2ScrollProbe; + const currentScroller = document.querySelector(selector); + return { + removedSections: probe?.removedSections ?? -1, + mainWasReplaced: + probe?.initialMain !== document.querySelector("#v2-main-content"), + scrollerWasReplaced: probe?.initialScroller !== currentScroller, + finalScrollTop: + currentScroller instanceof HTMLElement ? currentScroller.scrollTop : -1, + }; + }, scrollerSelector); + expect(probeAfterFetch.removedSections).toBe(0); + expect(probeAfterFetch.mainWasReplaced).toBe(false); + expect(probeAfterFetch.scrollerWasReplaced).toBe(false); + expect(probeAfterFetch.finalScrollTop).toBeGreaterThan(1000); +} + +const restorationCases = [ + { + name: "v2 search", + entryPath: "/v2/search", + heading: "すべてのメディア", + scrollerSelector: '[data-media-scroll="v2-search"]', + }, + { + name: "v2 source media", + entryPath: `/v2/sources/${E2E_SOURCE_ID}`, + heading: E2E_SOURCE_NAME, + scrollerSelector: `[data-media-scroll="${E2E_SOURCE_ID}"]`, + }, +] as const; +for (const restorationCase of restorationCases) { + for (const returnMethod of ["browser", "ui"] as const) { + test(`${restorationCase.name} keeps its restored scroller mounted during a fast page fetch via ${returnMethod} back`, async ({ + page, + }) => { + await verifyRestoredScrollerDuringFastPageFetch( + page, + restorationCase.entryPath, + restorationCase.heading, + restorationCase.scrollerSelector, + returnMethod, + ); + }); + } +} diff --git a/packages/ui/src/hooks/use-current-search-persistence.test.ts b/packages/ui/src/hooks/use-current-search-persistence.test.ts index 427c4290..00107d94 100644 --- a/packages/ui/src/hooks/use-current-search-persistence.test.ts +++ b/packages/ui/src/hooks/use-current-search-persistence.test.ts @@ -130,6 +130,13 @@ describe("useCurrentSearchPersistence", () => { expect(searchState.selectedSource).toBe("source-1"); }); + it("persists the initial state before the debounce expires", async () => { + mountPersistence("all"); + await flushMicrotasks(); + + expect(sessionStorage.getItem("current-all")).toContain('"mode":"simple"'); + }); + it("restores the latest source when the source accessor changes", async () => { const [sourceId, setSourceId] = createSignal("source-a"); sessionStorage.setItem( diff --git a/packages/ui/src/hooks/use-current-search-persistence.ts b/packages/ui/src/hooks/use-current-search-persistence.ts index 99eeb663..400fee44 100644 --- a/packages/ui/src/hooks/use-current-search-persistence.ts +++ b/packages/ui/src/hooks/use-current-search-persistence.ts @@ -152,6 +152,7 @@ export function useCurrentSearchPersistence( const [isRestored, setIsRestored] = createSignal(false); let debounceTimer: ReturnType | null = null; let restoreVersion = 0; + let lastPersistedPresetName: string | null = null; const getSourceId = () => typeof sourceId === "function" ? sourceId() : sourceId; @@ -209,28 +210,37 @@ export function useCurrentSearchPersistence( return; } - const condition = getSearchCondition() || { - type: "group" as const, - operator: "and" as const, - children: [], - }; - const presetData = { - value: condition, - selectedSource: searchState.selectedSource, - sort: searchState.sortBy, - order: searchState.sortOrder, - mode: searchState.mode, - similarityAnchorMediaId: searchState.similarityAnchorMediaId, - similarityTopK: searchState.similarityTopK, - }; - - debounceTimer = setTimeout(() => { + const persistCurrentState = () => { + const condition = getSearchCondition() || { + type: "group" as const, + operator: "and" as const, + children: [], + }; + const presetData = { + value: condition, + selectedSource: searchState.selectedSource, + sort: searchState.sortBy, + order: searchState.sortOrder, + mode: searchState.mode, + similarityAnchorMediaId: searchState.similarityAnchorMediaId, + similarityTopK: searchState.similarityTopK, + }; try { sessionStorage.setItem(presetName, JSON.stringify(presetData)); + lastPersistedPresetName = presetName; } catch { // Persistence errors must not disrupt the UI. } - }, DEBOUNCE_MS); + }; + + // Persist the initial state synchronously so navigating to a media detail + // before the debounce expires cannot reset the list on return. + if (lastPersistedPresetName !== presetName) { + persistCurrentState(); + return; + } + + debounceTimer = setTimeout(persistCurrentState, DEBOUNCE_MS); }); onCleanup(() => { diff --git a/packages/ui/src/hooks/use-search-page.ts b/packages/ui/src/hooks/use-search-page.ts index 2574be0f..cc7a1c2e 100644 --- a/packages/ui/src/hooks/use-search-page.ts +++ b/packages/ui/src/hooks/use-search-page.ts @@ -92,6 +92,7 @@ export interface UseSearchPageResult { searchResults: () => MediaSearchResponse["media"]; hasData: () => boolean; totalCount: () => number | undefined; + fetchNextPage: () => Promise; contentState: () => QueryUiState; filterStates: { tags: () => QueryUiState; @@ -134,6 +135,13 @@ export function useSearchPage( refreshDebounceMs = DEFAULT_REFRESH_DEBOUNCE_MS, isSearchStateRestored = () => true, } = options; + const queryClient = useQueryClient(); + const tagsQueryKey = queries.tags().queryKey; + const sourcesQueryKey = queries.sources().queryKey; + const projectsQueryKey = queries.projects().queryKey; + const ipsQueryKey = queries.ips().queryKey; + const charactersQueryKey = queries.characters().queryKey; + const authorsQueryKey = queries.authors().queryKey; const tags = createQuery(() => ({ ...queries.tags(), @@ -159,13 +167,52 @@ export function useSearchPage( ...queries.authors(), enabled: !isServer, })); + const [tagsData, setTagsData] = createSignal(); + const [sourcesData, setSourcesData] = createSignal< + SafeMediaSource[] | undefined + >(); + const [projectsData, setProjectsData] = createSignal(); + const [ipsData, setIpsData] = createSignal(); + const [charactersData, setCharactersData] = createSignal< + Character[] | undefined + >(); + const [authorsData, setAuthorsData] = createSignal(); + + createEffect(() => { + tags.dataUpdatedAt; + setTagsData(queryClient.getQueryData(tagsQueryKey)); + }); + createEffect(() => { + sources.dataUpdatedAt; + setSourcesData( + queryClient.getQueryData(sourcesQueryKey), + ); + }); + createEffect(() => { + allProjects.dataUpdatedAt; + setProjectsData(queryClient.getQueryData(projectsQueryKey)); + }); + createEffect(() => { + allIps.dataUpdatedAt; + setIpsData(queryClient.getQueryData(ipsQueryKey)); + }); + createEffect(() => { + allCharacters.dataUpdatedAt; + setCharactersData( + queryClient.getQueryData(charactersQueryKey), + ); + }); + createEffect(() => { + allAuthors.dataUpdatedAt; + setAuthorsData(queryClient.getQueryData(authorsQueryKey)); + }); const conditionKey = createMemo(() => JSON.stringify(getSearchCondition() ?? null), ); - const searchResultQueryOptions = createMemo(() => - buildSearchResultsQueryOptions({ + const searchResultQueryOptions = createMemo(() => { + return buildSearchResultsQueryOptions({ mode: mode(), sourceId: selectedSource() || undefined, condition: getSearchCondition(), @@ -179,10 +226,13 @@ export function useSearchPage( searchSimilar: options.searchSimilar, enabled: !isServer && isSearchStateRestored(), gcTime, - }), - ); + }); + }); const searchResultQuery = createInfiniteQuery(searchResultQueryOptions); - const queryClient = useQueryClient(); + const fetchNextPage = () => + isSearchStateRestored() + ? searchResultQuery.fetchNextPage() + : Promise.resolve(); const [searchResultData, setSearchResultData] = createSignal< InfiniteData | undefined >(); @@ -225,12 +275,24 @@ export function useSearchPage( }, { isEmpty: (data) => data.length === 0 }, ); - const arrayState = (query: { - data: T[] | undefined; - error: unknown; - status: "pending" | "error" | "success"; - fetchStatus: "idle" | "fetching" | "paused"; - }) => toQueryUiState(query, { isEmpty: (data) => data.length === 0 }); + const arrayState = ( + query: { + data: T[] | undefined; + error: unknown; + status: "pending" | "error" | "success"; + fetchStatus: "idle" | "fetching" | "paused"; + }, + data: () => T[] | undefined, + ) => + toQueryUiState( + { + data: data(), + error: query.error, + status: query.status, + fetchStatus: query.fetchStatus, + }, + { isEmpty: (items) => items.length === 0 }, + ); const retryFilters = async () => { await Promise.all([ tags.refetch(), @@ -243,7 +305,7 @@ export function useSearchPage( }; const getSourceRootPath = (mediaSourceId: string) => { - const source = sources.data?.find((item) => item.id === mediaSourceId); + const source = sourcesData()?.find((item) => item.id === mediaSourceId); if (source?.type !== "local") { return undefined; } @@ -282,10 +344,13 @@ export function useSearchPage( void key; setScrollY(position); }, - isReady: () => Boolean(searchResultData()) && !searchResultQuery.isLoading, + isReady: () => + isSearchStateRestored() && + Boolean(searchResultData()) && + !searchResultQuery.isLoading, hasNextPage: () => searchResultQuery.hasNextPage, isFetchingNextPage: () => searchResultQuery.isFetchingNextPage, - fetchNextPage: () => searchResultQuery.fetchNextPage(), + fetchNextPage, scrollContainerSelector: options.scrollContainerSelector, }); @@ -314,14 +379,14 @@ export function useSearchPage( const hasNextPage = searchResultQuery.hasNextPage; const isFetching = searchResultQuery.isFetching; - if (!hasNextPage || isFetching) { + if (!isSearchStateRestored() || !hasNextPage || isFetching) { return; } const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting) { - searchResultQuery.fetchNextPage(); + if (entries[0].isIntersecting && isSearchStateRestored()) { + fetchNextPage(); } }, { threshold: 0.5, rootMargin: "2400px" }, @@ -335,33 +400,34 @@ export function useSearchPage( searchResults, hasData: () => searchResultData() !== undefined, totalCount: () => searchResultData()?.pages[0]?.total, + fetchNextPage, contentState, filterStates: { - tags: () => arrayState(tags), - sources: () => arrayState(sources), - projects: () => arrayState(allProjects), - ips: () => arrayState(allIps), - characters: () => arrayState(allCharacters), - authors: () => arrayState(allAuthors), + tags: () => arrayState(tags, tagsData), + sources: () => arrayState(sources, sourcesData), + projects: () => arrayState(allProjects, projectsData), + ips: () => arrayState(allIps, ipsData), + characters: () => arrayState(allCharacters, charactersData), + authors: () => arrayState(allAuthors, authorsData), }, filterData: { get tags() { - return tags.data; + return tagsData(); }, get projects() { - return allProjects.data; + return projectsData(); }, get ips() { - return allIps.data; + return ipsData(); }, get characters() { - return allCharacters.data; + return charactersData(); }, get authors() { - return allAuthors.data; + return authorsData(); }, }, - sources: () => sources.data, + sources: sourcesData, getSourceRootPath, isRestored, handleSearch, diff --git a/packages/ui/src/screens/search-screen.tsx b/packages/ui/src/screens/search-screen.tsx index 4216b99d..41ae45a6 100644 --- a/packages/ui/src/screens/search-screen.tsx +++ b/packages/ui/src/screens/search-screen.tsx @@ -149,7 +149,7 @@ export function SearchScreen(props: SearchScreenProps) { isFetchingNextPage={page().searchResultQuery.isFetchingNextPage} mediaResults={page().searchResults} mediaSourceId={() => undefined} - onLoadMore={() => page().searchResultQuery.fetchNextPage()} + onLoadMore={page().fetchNextPage} onRetry={async () => { await page().searchResultQuery.refetch(); }} diff --git a/packages/ui/src/screens/v2-search-screen.tsx b/packages/ui/src/screens/v2-search-screen.tsx index ab457375..f661a6e8 100644 --- a/packages/ui/src/screens/v2-search-screen.tsx +++ b/packages/ui/src/screens/v2-search-screen.tsx @@ -92,7 +92,7 @@ export function V2SearchScreen(props: SearchScreenProps) { itemAspectRatio={4 / 3} mediaResults={page().searchResults} mediaSourceId={() => undefined} - onLoadMore={() => page().searchResultQuery.fetchNextPage()} + onLoadMore={page().fetchNextPage} onRetry={async () => { await page().searchResultQuery.refetch(); }} diff --git a/packages/ui/src/source-media-grid.tsx b/packages/ui/src/source-media-grid.tsx index 431a5963..ad038c41 100644 --- a/packages/ui/src/source-media-grid.tsx +++ b/packages/ui/src/source-media-grid.tsx @@ -297,10 +297,19 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { }, }); + const resolveScrollElement = () => + props.scrollMode === "element" + ? (mediaGridRef?.closest("[data-media-scroll]") as HTMLElement | null) + : null; + const updateMediaGridMetrics = () => { if (!mediaGridRef) return; setMediaGridWidth(mediaGridRef.getBoundingClientRect().width); - const scroller = scrollElement(); + const resolvedScrollElement = resolveScrollElement(); + if (resolvedScrollElement !== scrollElement()) { + setScrollElement(resolvedScrollElement); + } + const scroller = resolvedScrollElement; setScrollMargin( props.scrollMode === "element" && scroller ? mediaGridRef.getBoundingClientRect().top - @@ -312,11 +321,7 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { onMount(() => { setWindowWidth(window.innerWidth); - setScrollElement( - props.scrollMode === "element" - ? (mediaGridRef?.closest("[data-media-scroll]") as HTMLElement | null) - : null, - ); + setScrollElement(resolveScrollElement()); updateMediaGridMetrics(); const handleResize = () => { diff --git a/packages/ui/src/v2/search-toolbar.tsx b/packages/ui/src/v2/search-toolbar.tsx index ae9b4451..fa647d13 100644 --- a/packages/ui/src/v2/search-toolbar.tsx +++ b/packages/ui/src/v2/search-toolbar.tsx @@ -369,7 +369,6 @@ export function V2SearchToolbar(props: V2SearchToolbarProps) { /> Date: Thu, 6 Aug 2026 21:57:17 +0900 Subject: [PATCH 11/11] test: strengthen media grid scroll assertions --- .../src/tests/e2e/ui-components.gallery.spec.ts | 15 +++++++++++---- packages/ui/src/hooks/use-source-media-page.ts | 2 +- packages/ui/src/source-media-page.tsx | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/server/src/tests/e2e/ui-components.gallery.spec.ts b/apps/server/src/tests/e2e/ui-components.gallery.spec.ts index a87b545b..7d7acf1b 100644 --- a/apps/server/src/tests/e2e/ui-components.gallery.spec.ts +++ b/apps/server/src/tests/e2e/ui-components.gallery.spec.ts @@ -265,9 +265,16 @@ test("virtual media grid stays populated after restoring a deep scroll position" await page.goto(`${getGalleryUrl()}/?virtual-grid=1&restore-grid=1`); const scroller = page.getByTestId("virtual-grid-scroller"); + await expect(scroller.locator("[data-media-id]").first()).toBeVisible(); await expect - .poll(() => scroller.evaluate((element) => element.scrollTop)) + .poll(() => scroller.evaluate((element) => element.scrollHeight)) .toBeGreaterThan(1_000); + const maxScrollTop = await scroller.evaluate( + (element) => element.scrollHeight - element.clientHeight, + ); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(maxScrollTop * 0.9); await expectVisibleImagesLoaded(scroller); await scroller.evaluate((element) => { @@ -324,7 +331,7 @@ test("virtual media grid preserves offset after a route-style remount and page u .toBeGreaterThan(heightBeforeAppend); await expect .poll(() => scroller.evaluate((element) => element.scrollTop)) - .toBeGreaterThan(restoredTop + 100); + .toBeGreaterThan(restoredTop + 1_700); await expectVisibleImagesLoaded(scroller); }); @@ -346,7 +353,7 @@ test("virtual media grid preserves offset while fast scrolling starts a page fet await page.getByTestId("remount-virtual-grid").click(); await expect .poll(() => scroller.evaluate((element) => element.scrollTop)) - .toBeGreaterThan(1_000); + .toBeGreaterThan(1_400); await page.evaluate(() => window.dispatchEvent(new WheelEvent("wheel", { deltaY: 1 })), ); @@ -360,7 +367,7 @@ test("virtual media grid preserves offset while fast scrolling starts a page fet await expect(page.getByText("読み込み中...", { exact: true })).toHaveCount(0); await expect .poll(() => scroller.evaluate((element) => element.scrollTop)) - .toBeGreaterThan(restoredTop + 100); + .toBeGreaterThan(restoredTop + 1_000); }); test.describe("high-density virtual media grid", () => { diff --git a/packages/ui/src/hooks/use-source-media-page.ts b/packages/ui/src/hooks/use-source-media-page.ts index 37f72600..6987ac88 100644 --- a/packages/ui/src/hooks/use-source-media-page.ts +++ b/packages/ui/src/hooks/use-source-media-page.ts @@ -278,7 +278,7 @@ export function useSourceMediaPage( // Keep paginated results outside Solid Query's resource-backed `data` // accessor. A page fetch must not suspend and remount the media grid. const queryKey = mediaQueryOptions().queryKey; - mediaQuery.dataUpdatedAt; + void mediaQuery.dataUpdatedAt; const cachedData = queryClient.getQueryData>(queryKey); if (cachedData !== undefined) { diff --git a/packages/ui/src/source-media-page.tsx b/packages/ui/src/source-media-page.tsx index 9e6f30e2..cea80e2e 100644 --- a/packages/ui/src/source-media-page.tsx +++ b/packages/ui/src/source-media-page.tsx @@ -124,7 +124,7 @@ export function SourceMediaPage(props: SourceMediaPageProps): JSX.Element { isSearchStateRestored, scrollContainerSelector: props.variant === "v2" - ? `[data-media-scroll="${props.mediaSourceId()}"]` + ? `[data-media-scroll="${props.mediaSourceId() ?? "v2-source"}"]` : undefined, });