From 340d3b99ebb081790d96a89e442c222d422f4f4e Mon Sep 17 00:00:00 2001 From: hmjn Date: Sun, 2 Aug 2026 19:05:34 +0900 Subject: [PATCH 1/2] fix(ui): restore virtualized list scroll positions --- packages/ui/src/hooks/scroll-container.ts | 210 ++++++++++++++++++ packages/ui/src/hooks/use-search-page.ts | 33 ++- .../ui/src/hooks/use-source-media-page.ts | 59 +---- 3 files changed, 236 insertions(+), 66 deletions(-) diff --git a/packages/ui/src/hooks/scroll-container.ts b/packages/ui/src/hooks/scroll-container.ts index cb54bf29..c7841df0 100644 --- a/packages/ui/src/hooks/scroll-container.ts +++ b/packages/ui/src/hooks/scroll-container.ts @@ -1,3 +1,7 @@ +import type { Accessor } from "solid-js"; +import { createEffect, createSignal, onCleanup, onMount } from "solid-js"; +import { isServer } from "solid-js/web"; + export function resolveScrollContainer( selector: string | undefined, ): HTMLElement | null { @@ -26,3 +30,209 @@ export function currentScrollPosition( if (selector) return resolveScrollContainer(selector)?.scrollTop ?? null; return typeof window === "undefined" ? null : window.scrollY; } + +type ScrollMetrics = { + maximum: number; +}; + +function getScrollMetrics(selector: string | undefined): ScrollMetrics | null { + if (selector) { + const container = resolveScrollContainer(selector); + if (!container) return null; + return { + maximum: Math.max(0, container.scrollHeight - container.clientHeight), + }; + } + + if (typeof window === "undefined" || typeof document === "undefined") { + return null; + } + + return { + maximum: Math.max( + 0, + document.documentElement.scrollHeight - window.innerHeight, + ), + }; +} + +export type ScrollRestorationOptions = { + /** Identifies the result set whose position is being restored. */ + restoreKey: Accessor; + getPosition: (key: string) => number; + setPosition: (key: string, position: number) => void; + /** The result grid and its scroll container are ready to measure. */ + isReady: Accessor; + hasNextPage: Accessor; + isFetchingNextPage: Accessor; + fetchNextPage: () => Promise | unknown; + scrollContainerSelector?: string; +}; + +const MAX_RESTORE_PAGE_FETCHES = 100; +const RESTORE_SETTLE_DELAY_MS = 100; + +/** + * Restores a list's scroll position after its initial pages have rendered. + * + * Virtualized grids only expose the height of the pages they have loaded. When + * the saved position is beyond that height, fetch another page and retry after + * the grid grows instead of letting the browser clamp the scroll position. + */ +export function useScrollRestoration( + options: ScrollRestorationOptions, +): Accessor { + const [isRestored, setIsRestored] = createSignal(false); + let activeKey: string | undefined; + let cancelled = false; + let fetchCount = 0; + let fetchInFlight = false; + let frameId: number | undefined; + let settleTimer: ReturnType | undefined; + let restoreGeneration = 0; + + const requestRestore = () => { + if (frameId !== undefined || isServer) return; + frameId = requestAnimationFrame(() => { + frameId = undefined; + void attemptRestore(); + }); + }; + + const finishAt = (position: number, maximum: number) => { + const key = activeKey; + if (!key || cancelled) return; + const target = Math.min(Math.max(position, 0), maximum); + scrollToPosition(options.scrollContainerSelector, target); + if (target === 0) { + setIsRestored(true); + return; + } + + if (settleTimer !== undefined) clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + settleTimer = undefined; + if (cancelled || !activeKey) return; + const latestMetrics = getScrollMetrics(options.scrollContainerSelector); + if (!latestMetrics) { + requestRestore(); + return; + } + scrollToPosition( + options.scrollContainerSelector, + Math.min(target, latestMetrics.maximum), + ); + setIsRestored(true); + }, RESTORE_SETTLE_DELAY_MS); + }; + + const attemptRestore = async () => { + const key = activeKey; + const generation = restoreGeneration; + if ( + !key || + cancelled || + isRestored() || + settleTimer !== undefined || + !options.isReady() || + generation !== restoreGeneration + ) { + return; + } + + const metrics = getScrollMetrics(options.scrollContainerSelector); + if (!metrics) { + requestRestore(); + return; + } + + const target = Math.max(0, options.getPosition(key)); + if (target <= metrics.maximum + 1) { + finishAt(target, metrics.maximum); + return; + } + + if (!options.hasNextPage() || fetchCount >= MAX_RESTORE_PAGE_FETCHES) { + finishAt(target, metrics.maximum); + return; + } + + if (options.isFetchingNextPage() || fetchInFlight) return; + + fetchCount += 1; + fetchInFlight = true; + try { + await options.fetchNextPage(); + } catch { + // A failed prefetch should not leave the route permanently blocked. + if (generation === restoreGeneration) { + const latestMetrics = getScrollMetrics(options.scrollContainerSelector); + if (latestMetrics) finishAt(target, latestMetrics.maximum); + } + } finally { + fetchInFlight = false; + if (!cancelled && !isRestored()) requestRestore(); + } + }; + + const savePosition = () => { + if (isServer) return; + const key = activeKey; + if (!key) return; + const position = currentScrollPosition(options.scrollContainerSelector); + if (position !== null) options.setPosition(key, position); + }; + + createEffect(() => { + const key = options.restoreKey(); + if (key !== activeKey) { + savePosition(); + activeKey = key; + cancelled = false; + fetchCount = 0; + if (settleTimer !== undefined) { + clearTimeout(settleTimer); + settleTimer = undefined; + } + restoreGeneration += 1; + setIsRestored(false); + } + + // Track pagination transitions so a completed fetch retries restoration. + options.hasNextPage(); + options.isFetchingNextPage(); + if (key && options.isReady() && !isRestored() && !cancelled) { + requestRestore(); + } + }); + + onMount(() => { + if ("scrollRestoration" in history) { + history.scrollRestoration = "manual"; + } + + const cancelRestore = () => { + if (!isRestored()) cancelled = true; + }; + window.addEventListener("pointerdown", cancelRestore, { passive: true }); + window.addEventListener("wheel", cancelRestore, { passive: true }); + window.addEventListener("touchstart", cancelRestore, { passive: true }); + window.addEventListener("keydown", cancelRestore); + onCleanup(() => { + window.removeEventListener("pointerdown", cancelRestore); + window.removeEventListener("wheel", cancelRestore); + window.removeEventListener("touchstart", cancelRestore); + window.removeEventListener("keydown", cancelRestore); + }); + }); + + onCleanup(() => { + cancelled = true; + restoreGeneration += 1; + if (frameId !== undefined) cancelAnimationFrame(frameId); + if (settleTimer !== undefined) clearTimeout(settleTimer); + savePosition(); + }); + + return isRestored; +} diff --git a/packages/ui/src/hooks/use-search-page.ts b/packages/ui/src/hooks/use-search-page.ts index b8609617..e99ca5be 100644 --- a/packages/ui/src/hooks/use-search-page.ts +++ b/packages/ui/src/hooks/use-search-page.ts @@ -20,7 +20,7 @@ import { import { isServer } from "solid-js/web"; import { buildSearchResultsQueryOptions } from "../query-options"; import { type QueryUiState, toQueryUiState } from "../query-state"; -import { currentScrollPosition, scrollToPosition } from "./scroll-container"; +import { scrollToPosition, useScrollRestoration } from "./scroll-container"; const DEFAULT_GC_TIME = 1000 * 60 * 5; const DEFAULT_REFRESH_DEBOUNCE_MS = 0; @@ -247,27 +247,22 @@ export function useSearchPage( ); }; - const [isRestored, setIsRestored] = createSignal(false); - createEffect(() => { - if ( - !(searchResultQuery.isLoading || isRestored()) && - searchResultQuery.data && - searchResultQuery.data.pages.length > 0 - ) { - if (scrollY() > 0) { - requestAnimationFrame(() => { - scrollToPosition(options.scrollContainerSelector, scrollY()); - }); - } - setIsRestored(true); - } + const isRestored = useScrollRestoration({ + restoreKey: () => "search", + getPosition: () => scrollY(), + setPosition: (key, position) => { + void key; + setScrollY(position); + }, + isReady: () => + Boolean(searchResultQuery.data) && !searchResultQuery.isLoading, + hasNextPage: () => searchResultQuery.hasNextPage, + isFetchingNextPage: () => searchResultQuery.isFetchingNextPage, + fetchNextPage: () => searchResultQuery.fetchNextPage(), + scrollContainerSelector: options.scrollContainerSelector, }); onCleanup(() => { - if (!isServer) { - const position = currentScrollPosition(options.scrollContainerSelector); - if (position !== null) setScrollY(position); - } const timer = refreshTimer(); if (timer) { clearTimeout(timer); diff --git a/packages/ui/src/hooks/use-source-media-page.ts b/packages/ui/src/hooks/use-source-media-page.ts index e331e5e9..c8d09695 100644 --- a/packages/ui/src/hooks/use-source-media-page.ts +++ b/packages/ui/src/hooks/use-source-media-page.ts @@ -36,12 +36,11 @@ import { type QueryUiState, toQueryUiState } from "../query-state"; import type { PresetManagerClient } from "../search-control-panel"; import { toast } from "../toast"; import { getRestoreImportStrategies } from "./restore-import"; -import { currentScrollPosition, scrollToPosition } from "./scroll-container"; +import { scrollToPosition, useScrollRestoration } from "./scroll-container"; import type { MediaSourceEventTransport } from "./use-media-source-events"; import { useMediaSourceEvents } from "./use-media-source-events"; export const SOURCE_MEDIA_ITEMS_PER_PAGE = 200; -const SCROLL_RESTORE_DELAY = 100; const DEBOUNCE_DELAY_MS = 1000; const MEDIA_REFRESH_DEBOUNCE_MS = 300; @@ -295,55 +294,21 @@ export function useSourceMediaPage( // --- Search handler --- const handleSearch = () => { + const sourceId = id(); + if (sourceId) setScrollPosition(sourceId, 0); scrollToPosition(options.scrollContainerSelector, 0); }; // --- Scroll restoration --- - const [isScrollRestored, setIsScrollRestored] = createSignal(false); - - onMount(() => { - if ("scrollRestoration" in history) { - history.scrollRestoration = "manual"; - } - }); - - createEffect(() => { - if (isServer) { - return; - } - if (isScrollRestored()) { - return; - } - - const sourceId = id(); - if (!sourceId) { - return; - } - - if (mediaQuery.data && !mediaQuery.isLoading) { - const targetScrollY = getScrollPosition(sourceId); - if (targetScrollY > 0) { - setTimeout(() => { - requestAnimationFrame(() => { - scrollToPosition(options.scrollContainerSelector, targetScrollY); - setIsScrollRestored(true); - }); - }, SCROLL_RESTORE_DELAY); - } else { - setIsScrollRestored(true); - } - } - }); - - onCleanup(() => { - if (isServer) { - return; - } - const sourceId = id(); - if (sourceId) { - const position = currentScrollPosition(options.scrollContainerSelector); - if (position !== null) setScrollPosition(sourceId, position); - } + useScrollRestoration({ + restoreKey: id, + getPosition: (sourceId) => getScrollPosition(sourceId), + setPosition: (sourceId, position) => setScrollPosition(sourceId, position), + isReady: () => Boolean(mediaQuery.data) && !mediaQuery.isLoading, + hasNextPage: () => mediaQuery.hasNextPage, + isFetchingNextPage: () => mediaQuery.isFetchingNextPage, + fetchNextPage: () => mediaQuery.fetchNextPage(), + scrollContainerSelector: options.scrollContainerSelector, }); // --- Infinite scroll --- From e5d9cd5ca72fb883226de85153d0b0c1eb1ff12c Mon Sep 17 00:00:00 2001 From: hmjn Date: Sun, 2 Aug 2026 20:30:44 +0900 Subject: [PATCH 2/2] fix(ui): avoid overwriting pending scroll restoration --- packages/ui/src/hooks/scroll-container.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/hooks/scroll-container.ts b/packages/ui/src/hooks/scroll-container.ts index c7841df0..f83c73cd 100644 --- a/packages/ui/src/hooks/scroll-container.ts +++ b/packages/ui/src/hooks/scroll-container.ts @@ -177,6 +177,7 @@ export function useScrollRestoration( const savePosition = () => { if (isServer) return; + if (!isRestored()) return; const key = activeKey; if (!key) return; const position = currentScrollPosition(options.scrollContainerSelector);