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/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/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/ui-components.gallery.spec.ts b/apps/server/src/tests/e2e/ui-components.gallery.spec.ts index 98fffe76..7d7acf1b 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,118 @@ 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(scroller.locator("[data-media-id]").first()).toBeVisible(); + await expect + .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) => { + element.scrollTop += element.clientHeight * 2; + }); + 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 + 1_700); + 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_400); + 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 + 1_000); +}); + 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..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 { @@ -62,6 +63,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, @@ -100,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 } from "solid-js"; +import { createEffect, createSignal, onCleanup, Show } from "solid-js"; import { render } from "solid-js/web"; import "../../../app.css"; @@ -132,58 +134,165 @@ const VIRTUAL_GRID_MEDIA = Array.from( ); function VirtualGridGallery() { + const params = new URLSearchParams(window.location.search); + 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 ( + !pagedGrid || + isFetchingNextPage() || + loadedCount() >= VIRTUAL_GRID_ITEM_COUNT + ) { + return; + } + setIsFetchingNextPage(true); + 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]", + ); + 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={setLoadMoreRef} + showResultCount={false} + state={() => ({ + data: mediaResults(), + error: undefined, + fetchState: "idle", + phase: "data", + })} + totalCount={VIRTUAL_GRID_ITEM_COUNT} + /> + ); + }; + return ( -
+

Virtual media grid performance fixture

+ +
+ + +
+
+
+
+ +
- VIRTUAL_GRID_MEDIA} - mediaSourceId={() => VIRTUAL_GRID_MEDIA[0]?.mediaSourceId} - 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: VIRTUAL_GRID_MEDIA, - error: undefined, - fetchState: "idle", - phase: "data", - })} - totalCount={VIRTUAL_GRID_ITEM_COUNT} - /> +
+
+ + + +
+
); 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/scroll-container.ts b/packages/ui/src/hooks/scroll-container.ts index f83c73cd..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,8 +214,57 @@ 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; + 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 }); window.addEventListener("wheel", cancelRestore, { passive: true }); @@ -232,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(); }); 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..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( @@ -185,6 +192,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..400fee44 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; } @@ -149,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; @@ -206,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 e99ca5be..cc7a1c2e 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,9 @@ export interface UseSearchPageResult { typeof createInfiniteQuery >; searchResults: () => MediaSearchResponse["media"]; + hasData: () => boolean; + totalCount: () => number | undefined; + fetchNextPage: () => Promise; contentState: () => QueryUiState; filterStates: { tags: () => QueryUiState; @@ -127,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(), @@ -152,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 searchResultQuery = createInfiniteQuery(() => - buildSearchResultsQueryOptions({ + const searchResultQueryOptions = createMemo(() => { + return buildSearchResultsQueryOptions({ mode: mode(), sourceId: selectedSource() || undefined, condition: getSearchCondition(), @@ -172,12 +226,36 @@ export function useSearchPage( searchSimilar: options.searchSimilar, enabled: !isServer && isSearchStateRestored(), gcTime, - }), - ); + }); + }); + const searchResultQuery = createInfiniteQuery(searchResultQueryOptions); + const fetchNextPage = () => + isSearchStateRestored() + ? searchResultQuery.fetchNextPage() + : Promise.resolve(); + 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,19 +268,31 @@ export function useSearchPage( const contentState = () => toQueryUiState( { - data: searchResultQuery.data ? searchResults() : undefined, + data: searchResultData() ? searchResults() : undefined, error: searchResultQuery.error, status: searchResultQuery.status, fetchStatus: searchResultQuery.fetchStatus, }, { 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(), @@ -215,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; } @@ -255,10 +345,12 @@ export function useSearchPage( setScrollY(position); }, isReady: () => - Boolean(searchResultQuery.data) && !searchResultQuery.isLoading, + isSearchStateRestored() && + Boolean(searchResultData()) && + !searchResultQuery.isLoading, hasNextPage: () => searchResultQuery.hasNextPage, isFetchingNextPage: () => searchResultQuery.isFetchingNextPage, - fetchNextPage: () => searchResultQuery.fetchNextPage(), + fetchNextPage, scrollContainerSelector: options.scrollContainerSelector, }); @@ -287,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" }, @@ -306,33 +398,36 @@ export function useSearchPage( return { searchResultQuery, 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/hooks/use-source-media-page.ts b/packages/ui/src/hooks/use-source-media-page.ts index c8d09695..6987ac88 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; + void 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..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(); }} @@ -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) {