diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index cb267e0..74da292 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -24,10 +24,14 @@ import type { MediaShellItem } from '@/lib/types/media'; export default function GalleryPage() { const { items: shellItems, + windowStart, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, + fetchPreviousPage, + hasPreviousPage, + isFetchingPreviousPage, seekToIndex, isSeeking, } = useShellData(); @@ -121,6 +125,13 @@ export default function GalleryPage() { // "more is coming" state as a scroll-driven page fetch, and it // also keeps the grid's scroll lookahead from racing it. isLoadingMore={isSearchActive ? false : isFetchingNextPage || isSeeking} + // The window slides both ways: scrolling back up past the + // evicted edge refetches the page above. `isSeeking` blocks the + // top lookahead during a jump for the same reason as the bottom. + onLoadPrevious={isSearchActive ? undefined : fetchPreviousPage} + hasPrevious={isSearchActive ? false : hasPreviousPage} + isLoadingPrevious={isSearchActive ? false : isFetchingPreviousPage || isSeeking} + windowStart={isSearchActive ? 0 : windowStart} onSeekToIndex={isSearchActive ? undefined : seekToIndex} /> diff --git a/frontend/src/components/gallery/GalleryGrid.tsx b/frontend/src/components/gallery/GalleryGrid.tsx index 7f5270e..11a803d 100644 --- a/frontend/src/components/gallery/GalleryGrid.tsx +++ b/frontend/src/components/gallery/GalleryGrid.tsx @@ -63,6 +63,14 @@ interface GalleryGridProps { hasMore?: boolean; /** Whether a page fetch triggered by onLoadMore is in flight. */ isLoadingMore?: boolean; + /** Called when the scroll position nears the start of the loaded window. */ + onLoadPrevious?: () => void; + /** Whether a page exists above the loaded window. */ + hasPrevious?: boolean; + /** Whether a page fetch triggered by onLoadPrevious is in flight. */ + isLoadingPrevious?: boolean; + /** Global index of the first loaded item; the window's absolute start. */ + windowStart?: number; /** Lets a timeline jump load the page holding an item index directly. */ onSeekToIndex?: (index: number) => Promise; } @@ -94,6 +102,10 @@ export function GalleryGrid({ onLoadMore, hasMore, isLoadingMore, + onLoadPrevious, + hasPrevious, + isLoadingPrevious, + windowStart = 0, onSeekToIndex, }: GalleryGridProps) { const containerRef = useRef(null); @@ -282,6 +294,59 @@ export function GalleryGrid({ // own identity would loop. }, [virtualRows]); + /** + * Keep the viewport pinned to the same content when the window slides. + * + * The window is a fixed span of pages: scrolling down evicts the top page and + * scrolling up prepends one. Either way the row set above the viewport changes, + * so every remaining row shifts and — because the browser keeps `scrollTop` + * numerically fixed — the content under the user would jump. This corrects for + * it by measuring the row straddling the top edge before and after the change + * and adjusting `scrollTop` by the exact delta. + * + * Heights are exact (justified layout is a pure function of the stored + * dimensions), so this is a correction, not an estimate. A jump replaces the + * whole window, so its anchor key is gone — the effect no-ops and the scrollbar + * lands the jump itself. Runs in a layout effect so the fix lands before paint. + */ + const prevRowsRef = useRef(null); + useLayoutEffect(() => { + const container = containerRef.current; + const prev = prevRowsRef.current; + prevRowsRef.current = virtualRows; + if (!container || !prev || prev === virtualRows) return; + + const scrollTop = container.scrollTop; + if (scrollTop <= 0) return; // pinned to the top: nothing above to preserve + + // Row occupying the top edge in the OLD layout, and its offset. + let acc = 0; + let anchorKey: string | null = null; + let anchorOld = 0; + for (const r of prev) { + if (acc > scrollTop) break; + anchorKey = r.key; + anchorOld = acc; + acc += r.height; + } + if (!anchorKey) return; + + // The same row's offset in the NEW layout. + let anchorNew: number | null = null; + let nacc = 0; + for (const r of virtualRows) { + if (r.key === anchorKey) { + anchorNew = nacc; + break; + } + nacc += r.height; + } + if (anchorNew === null) return; // anchor evicted (a jump) — leave it to the scrollbar + + const delta = anchorNew - anchorOld; + if (delta !== 0) container.scrollTop = scrollTop + delta; + }, [virtualRows]); + const virtualItems = virtualizer.getVirtualItems(); /** @@ -302,6 +367,18 @@ export function GalleryGrid({ } }, [distanceToEnd, viewportHeight, totalSize, hasMore, isLoadingMore, onLoadMore]); + /** + * Mirror of the above for the top edge: once the window has slid down (front + * pages evicted), scrolling back up refetches the page above before reaching + * it. The re-anchor effect keeps the viewport still as the page prepends. + */ + useEffect(() => { + if (!hasPrevious || isLoadingPrevious || totalSize <= 0) return; + if (scrollOffset <= viewportHeight * LOAD_MORE_VIEWPORT_LOOKAHEAD) { + onLoadPrevious?.(); + } + }, [scrollOffset, viewportHeight, totalSize, hasPrevious, isLoadingPrevious, onLoadPrevious]); + return (
diff --git a/frontend/src/components/gallery/PhotoGallery.tsx b/frontend/src/components/gallery/PhotoGallery.tsx index 8d162df..5d1bb8b 100644 --- a/frontend/src/components/gallery/PhotoGallery.tsx +++ b/frontend/src/components/gallery/PhotoGallery.tsx @@ -47,6 +47,14 @@ interface PhotoGalleryProps { hasMore?: boolean; /** Whether a page fetch triggered by onLoadMore is in flight. */ isLoadingMore?: boolean; + /** Called when the grid scrolls near the start of the loaded window. */ + onLoadPrevious?: () => void; + /** Whether a page exists above the loaded window (it slid down past it). */ + hasPrevious?: boolean; + /** Whether a page fetch triggered by onLoadPrevious is in flight. */ + isLoadingPrevious?: boolean; + /** Global index of the first loaded item; the window's absolute start. */ + windowStart?: number; /** * Loads the page holding a global item index directly. Lets the timeline jump * to a month far below the loaded range without walking every page to it. @@ -68,6 +76,10 @@ export function PhotoGallery({ onLoadMore, hasMore, isLoadingMore, + onLoadPrevious, + hasPrevious, + isLoadingPrevious, + windowStart, onSeekToIndex, }: PhotoGalleryProps) { const [lightboxId, setLightboxId] = useState(null); @@ -163,6 +175,10 @@ export function PhotoGallery({ onLoadMore={onLoadMore} hasMore={hasMore} isLoadingMore={isLoadingMore} + onLoadPrevious={onLoadPrevious} + hasPrevious={hasPrevious} + isLoadingPrevious={isLoadingPrevious} + windowStart={windowStart} onSeekToIndex={onSeekToIndex} />
diff --git a/frontend/src/components/gallery/TimelineScrollbar.tsx b/frontend/src/components/gallery/TimelineScrollbar.tsx index 8a39bb1..bd85cd9 100644 --- a/frontend/src/components/gallery/TimelineScrollbar.tsx +++ b/frontend/src/components/gallery/TimelineScrollbar.tsx @@ -14,6 +14,8 @@ interface TimelineScrollbarProps { hasMore?: boolean; /** Lets a jump to an unloaded month pull the pages it needs. */ onLoadMore?: () => void; + /** Global index of the first loaded row; the loaded window's absolute start. */ + windowStart?: number; /** Loads the page holding a global item index directly, skipping the rest. */ onSeekToIndex?: (index: number) => Promise; } @@ -24,6 +26,7 @@ export function TimelineScrollbar({ timeline: timelineProp, hasMore, onLoadMore, + windowStart, onSeekToIndex, }: TimelineScrollbarProps) { const { data: globalTimeline } = useTimeline(); @@ -42,6 +45,7 @@ export function TimelineScrollbar({ } = useTimelineScrollbar(containerRef, virtualRows, timeline, { hasMore, onLoadMore, + windowStart, onSeekToIndex, }); diff --git a/frontend/src/lib/hooks/useShellData.ts b/frontend/src/lib/hooks/useShellData.ts index 6386dbe..d31f17e 100644 --- a/frontend/src/lib/hooks/useShellData.ts +++ b/frontend/src/lib/hooks/useShellData.ts @@ -6,50 +6,61 @@ import { toast } from 'sonner'; import { getProcessingUpdates, getShellData } from '../api/media'; import { queryKeys } from '../queries/keys'; import { - appendShellPages, countLoadedItems, mapWithConcurrency, - missingPageOffsets, + seekWindowOffsets, SEEK_CONCURRENCY, SHELL_PAGE_SIZE, + WINDOW_PAGES, } from '../utils/shellPaging'; import type { MediaShellItem } from '../types/media'; import type { CursorPaginatedResponse } from '../types/api'; type ShellPages = { pages: Array>; - pageParams: Array; + // Each page's param is its offset (the global index of its first item), which + // is what makes the loaded set a window that can start anywhere rather than a + // prefix from index 0. + pageParams: number[]; }; /** - * The gallery's item list. + * The gallery's item list, as a *sliding window* over the library. * - * Two architectural fixes here. + * Three architectural fixes here. * - * 1. Paginated. `/media/shell` used to return the entire library in one response, so - * nothing painted until the whole thing had transferred, parsed, grouped and laid - * out, and the array was then retained for the session. Pages now arrive as the - * user scrolls, and the timeline-counts endpoint supplies total scroll height so - * the scrollbar is correct from the first frame. + * 1. Windowed. `/media/shell` used to return the entire library in one response; + * then it was paginated but every page was retained for the session, so memory + * and per-append layout cost grew without bound until the tab crashed. React + * Query's `maxPages` now caps retention at `WINDOW_PAGES` and drops the far page + * as the user scrolls, so the held item count is bounded no matter how far the + * library is scrolled. Pages are addressed by *offset* so the window can be + * rebuilt anywhere for a timeline jump. * * 2. The processing poll is a *narrow* query. It used to re-fetch the entire shell - * payload every 5 seconds while any item was PENDING/PROCESSING — so one row - * wedged in PROCESSING (which nothing could reconcile) made every open tab - * re-download and re-lay-out the whole library indefinitely. A small changed-ids - * feed now patches the cached pages in place. + * payload every 5 seconds while any item was PENDING/PROCESSING. A small + * changed-ids feed now patches the cached pages in place. */ export function useShellData() { const queryClient = useQueryClient(); const query = useInfiniteQuery({ queryKey: queryKeys.media.shell(), - // `limit` is explicit so every page holds exactly SHELL_PAGE_SIZE items, - // which is what makes the offsets a timeline jump computes land on real - // page boundaries. + // Offset addressing (not cursor): a window can start at any page, and + // `getPreviousPageParam` needs to walk backwards, which a forward-only + // cursor cannot. Offsets are exact against an unchanged table; an upload + // mid-session shifts them by one at a window edge, which the `['media']` + // invalidation on mutation resets. queryFn: ({ pageParam }) => - getShellData({ limit: SHELL_PAGE_SIZE, ...(pageParam ? { cursor: pageParam } : {}) }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + getShellData({ offset: pageParam, limit: SHELL_PAGE_SIZE }), + initialPageParam: 0, + getNextPageParam: (lastPage, _all, lastParam) => + lastPage.nextCursor ? lastParam + SHELL_PAGE_SIZE : undefined, + getPreviousPageParam: (_firstPage, _all, firstParam) => + firstParam > 0 ? Math.max(0, firstParam - SHELL_PAGE_SIZE) : undefined, + // The window. `maxPages` requires both param getters above to be defined so + // it can drop from either end. + maxPages: WINDOW_PAGES, staleTime: 60_000, }); @@ -58,6 +69,14 @@ export function useShellData() { [query.data] ); + /** + * Absolute index of the first loaded item — the offset of the first retained + * page. Zero on a fresh load and while the window sits at the top; grows as + * front pages are evicted during downward scroll. The scrollbar maps a global + * item index to a row by subtracting this. + */ + const windowStart = (query.data?.pageParams[0] as number | undefined) ?? 0; + const hasPending = useMemo( () => items.some( @@ -115,28 +134,30 @@ export function useShellData() { /** * `cancelRefetch: false` is load-bearing. * - * React Query defaults it to true, so a second `fetchNextPage()` while one is - * in flight *aborts and restarts* it. Both the grid's scroll lookahead and the - * timeline scrollbar's jump-chasing ask for pages, and a jump asks on every - * drag frame — with the default the two livelocked, re-requesting the same - * cursor indefinitely and never advancing past it. + * React Query defaults it to true, so a second fetch while one is in flight + * aborts and restarts it. Both the grid's scroll lookahead (either edge) and + * the timeline scrollbar ask for pages, and a jump asks on every drag frame — + * with the default the two livelocked, re-requesting the same page forever. */ - const { fetchNextPage } = query; + const { fetchNextPage, fetchPreviousPage } = query; const loadMore = useCallback(() => { - // Result is ignored: errors surface through the query, not this promise. fetchNextPage({ cancelRefetch: false }); }, [fetchNextPage]); + const loadPrevious = useCallback(() => { + fetchPreviousPage({ cancelRefetch: false }); + }, [fetchPreviousPage]); /** - * Bring the page holding a given *global* item index into the cache. + * Rebuild the window around a *global* item index for a timeline jump. * - * This is what the timeline scrollbar calls when the month a user picked is - * below the loaded range. Walking there with `fetchNextPage` cost one round - * trip per 2000 items and rendered every page on the way; addressing the gap - * by offset collapses it into a couple of parallel batches. + * This is what the scrollbar calls when the month a user picked is outside the + * loaded window. Rather than walk pages to it (one round trip each, rendering + * every page on the way), it fetches a fresh window of pages centred on the + * target in parallel and replaces the cached pages wholesale. Subsequent + * scroll-driven `fetchNextPage`/`fetchPreviousPage` continue from the new + * window's edges. * - * Rejects if the fetch fails, so the caller can release the jump it is holding - * rather than wait on a landing that will never come. + * Rejects if the fetch fails, so the caller can release the jump it is holding. */ const seekInFlightRef = useRef(false); const [isSeeking, setIsSeeking] = useState(false); @@ -149,25 +170,37 @@ export function useShellData() { const cached = queryClient.getQueryData(key); if (!cached || cached.pages.length === 0) return; + // Already inside the loaded window — nothing to fetch; the scrollbar + // lands on it directly. + const start = cached.pageParams[0] ?? 0; const loaded = countLoadedItems(cached.pages); - const offsets = missingPageOffsets(loaded, targetIndex); - // Nothing missing, or the library ends before the target. - if (offsets.length === 0 || !cached.pages[cached.pages.length - 1]?.nextCursor) return; + if (targetIndex >= start && targetIndex < start + loaded) return; + + const offsets = seekWindowOffsets(targetIndex); + if (offsets.length === 0) return; seekInFlightRef.current = true; setIsSeeking(true); try { - // A page fetch already in flight would append itself on top of - // whatever this writes, duplicating rows. + // Any in-flight page fetch would append onto whatever this writes. await queryClient.cancelQueries({ queryKey: key }); const fetched = await mapWithConcurrency(offsets, SEEK_CONCURRENCY, (offset) => getShellData({ offset, limit: SHELL_PAGE_SIZE }).then((page) => ({ offset, page })) ); - queryClient.setQueryData(key, (previous) => - previous ? appendShellPages(previous, fetched) : previous - ); + // Keep the leading contiguous, non-empty pages. An offset past the + // end of the library returns an empty page and ends the window. + const pages: ShellPages['pages'] = []; + const pageParams: number[] = []; + for (const { offset, page } of fetched) { + if (page.items.length === 0) break; + pages.push(page); + pageParams.push(offset); + } + if (pages.length === 0) return; + + queryClient.setQueryData(key, { pages, pageParams }); } catch (err) { toast.error('Could not jump to that date'); @@ -183,12 +216,16 @@ export function useShellData() { return { items, + windowStart, isLoading: query.isLoading, isError: query.isError, error: query.error, fetchNextPage: loadMore, hasNextPage: query.hasNextPage, isFetchingNextPage: query.isFetchingNextPage, + fetchPreviousPage: loadPrevious, + hasPreviousPage: query.hasPreviousPage, + isFetchingPreviousPage: query.isFetchingPreviousPage, seekToIndex, isSeeking, }; diff --git a/frontend/src/lib/hooks/useTimelineScrollbar.ts b/frontend/src/lib/hooks/useTimelineScrollbar.ts index 4e3df11..04baa83 100644 --- a/frontend/src/lib/hooks/useTimelineScrollbar.ts +++ b/frontend/src/lib/hooks/useTimelineScrollbar.ts @@ -35,6 +35,12 @@ interface UseTimelineScrollbarOptions { hasMore?: boolean; /** Requests the next page. The fallback when `onSeekToIndex` is absent. */ onLoadMore?: () => void; + /** + * Global index of the first loaded row. The loaded rows are a *window* that can + * start anywhere in the library, so a global item index maps to a loaded row by + * subtracting this, and a loaded row's local index maps back by adding it. + */ + windowStart?: number; /** * Loads the page holding a global item index directly, skipping the ones in * between. Rejects if the fetch fails. @@ -69,7 +75,7 @@ export function useTimelineScrollbar( containerRef: RefObject, virtualRows: VirtualRow[], timeline: TimelineMonth[] | undefined, - { hasMore = false, onLoadMore, onSeekToIndex }: UseTimelineScrollbarOptions = {}, + { hasMore = false, onLoadMore, windowStart = 0, onSeekToIndex }: UseTimelineScrollbarOptions = {}, ): UseTimelineScrollbarResult { const [thumbFraction, setThumbFraction] = useState(0); const [activeLabel, setActiveLabel] = useState(null); @@ -154,7 +160,7 @@ export function useTimelineScrollbar( * correction existed, no longer distorts anything because a fraction maps to a * row rather than to a pixel offset. */ - const totalItems = Math.max(totalItemsInTimeline(timeline), rowIndex.loadedItems); + const totalItems = Math.max(totalItemsInTimeline(timeline), windowStart + rowIndex.loadedItems); // Measure container height useEffect(() => { @@ -184,10 +190,10 @@ export function useTimelineScrollbar( const fraction = atEnd && !hasMore ? 1 : totalItems > 1 - ? itemIndexAtScrollTop(rowIndex, container.scrollTop) / (totalItems - 1) + ? (windowStart + itemIndexAtScrollTop(rowIndex, container.scrollTop)) / (totalItems - 1) : 0; setThumbFraction(Math.max(0, Math.min(1, fraction))); - }, [containerRef, rowIndex, totalItems, hasMore]); + }, [containerRef, rowIndex, totalItems, hasMore, windowStart]); // Track scroll position → thumb fraction (direct 1:1 mapping) useEffect(() => { @@ -364,7 +370,9 @@ export function useTimelineScrollbar( if (totalItems <= 0) return; const targetIndex = Math.min(totalItems - 1, Math.round(clamped * (totalItems - 1))); - const scrollTop = scrollTopForItemIndex(rowIndex, targetIndex); + // targetIndex is a global library index; the loaded rows are a window + // starting at windowStart, so map into local row space to find its offset. + const scrollTop = scrollTopForItemIndex(rowIndex, targetIndex - windowStart); if (scrollTop === null) { /** @@ -392,7 +400,7 @@ export function useTimelineScrollbar( ? formatDate(currentDate) : (findMarkerAtFraction(markers, clamped)?.label ?? null) ); - }, [containerRef, markers, dateIndex, rowIndex, totalItems, requestPages, setPendingIndex, commitLabel]); + }, [containerRef, markers, dateIndex, rowIndex, totalItems, windowStart, requestPages, setPendingIndex, commitLabel]); /** * Land a jump once the pages holding its target arrive. @@ -412,7 +420,7 @@ export function useTimelineScrollbar( const container = containerRef.current; if (!container) return; - const scrollTop = scrollTopForItemIndex(rowIndex, target); + const scrollTop = scrollTopForItemIndex(rowIndex, target - windowStart); if (scrollTop !== null) { setPendingIndex(null); container.scrollTop = scrollTop; @@ -431,7 +439,7 @@ export function useTimelineScrollbar( const maxScroll = container.scrollHeight - container.clientHeight; if (maxScroll > 0) container.scrollTop = maxScroll; } - }, [containerRef, rowIndex, dateIndex, hasMore, requestPages, setPendingIndex, commitLabel]); + }, [containerRef, rowIndex, dateIndex, hasMore, windowStart, requestPages, setPendingIndex, commitLabel]); // rAF-throttle drag updates: pointermove fires far more often than the // display refreshes, so coalesce to at most one scrollTop write per frame. diff --git a/frontend/src/lib/utils/shellPaging.test.ts b/frontend/src/lib/utils/shellPaging.test.ts index 769028f..a9d45e7 100644 --- a/frontend/src/lib/utils/shellPaging.test.ts +++ b/frontend/src/lib/utils/shellPaging.test.ts @@ -1,131 +1,32 @@ import { describe, it, expect } from 'vitest'; -import { - appendShellPages, - countLoadedItems, - mapWithConcurrency, - missingPageOffsets, -} from './shellPaging'; +import { countLoadedItems, mapWithConcurrency, seekWindowOffsets } from './shellPaging'; -/** A shell page holding `count` items starting at global index `start`. */ -function page(start: number, count: number, nextCursor: string | null = `i${start + count - 1}`) { - return { - items: Array.from({ length: count }, (_, i) => ({ id: `i${start + i}` })), - nextCursor, - hasMore: nextCursor !== null, - }; -} - -describe('missingPageOffsets', () => { - it('asks for nothing when the target is already loaded', () => { - expect(missingPageOffsets(4000, 0, 2000)).toEqual([]); - expect(missingPageOffsets(4000, 3999, 2000)).toEqual([]); +describe('seekWindowOffsets', () => { + it('keeps one page of lead above the target when the library allows', () => { + // target 5000, page 300 → target page 16 (offset 4800); lead one page. + expect(seekWindowOffsets(5000, 300, 5)).toEqual([4500, 4800, 5100, 5400, 5700]); }); - it('asks for one page when the target is inside the next one', () => { - // 4000 items loaded → the next page covers indices 4000..5999. - expect(missingPageOffsets(4000, 4000, 2000)).toEqual([4000]); - expect(missingPageOffsets(4000, 5999, 2000)).toEqual([4000]); + it('clamps the window start at page 0 near the top', () => { + expect(seekWindowOffsets(100, 300, 5)).toEqual([0, 300, 600, 900, 1200]); + expect(seekWindowOffsets(0, 300, 5)).toEqual([0, 300, 600, 900, 1200]); }); - /** - * The case the whole change is about: the user picks a month five pages down. - * These offsets are fetched at once, rather than the gap being walked one - * `fetchNextPage` round trip at a time with the grid rendering each page. - */ - it('asks for every page between the loaded range and the target', () => { - expect(missingPageOffsets(4000, 11000, 2000)).toEqual([4000, 6000, 8000, 10000]); + it('always returns exactly windowPages offsets (trailing ones may be past the end)', () => { + expect(seekWindowOffsets(9_000_000, 300, 3)).toHaveLength(3); }); - it('caps a batch so one click cannot queue an unbounded fan-out', () => { - const offsets = missingPageOffsets(0, 1_000_000, 2000, 8); - - expect(offsets).toHaveLength(8); - expect(offsets[0]).toBe(0); - expect(offsets[7]).toBe(14000); + it('returns nothing for a nonsensical request', () => { + expect(seekWindowOffsets(-1)).toEqual([]); + expect(seekWindowOffsets(100, 0)).toEqual([]); }); }); -describe('appendShellPages', () => { - it('appends in order, recording the cursor that would have produced each page', () => { - const previous = { pages: [page(0, 3)], pageParams: [undefined] as Array }; - - const next = appendShellPages(previous, [ - { offset: 3, page: page(3, 3) }, - { offset: 6, page: page(6, 3) }, - ]); - - expect(next.pages.flatMap((p) => p.items.map((i) => i.id))).toEqual([ - 'i0', 'i1', 'i2', 'i3', 'i4', 'i5', 'i6', 'i7', 'i8', - ]); - // Each appended page records its predecessor's cursor, so a later refetch - // of the whole query walks back to exactly this list. - expect(next.pageParams).toEqual([undefined, 'i2', 'i5']); - }); - - it('leaves the previous pages untouched', () => { - const previous = { pages: [page(0, 3)], pageParams: [undefined] as Array }; - - appendShellPages(previous, [{ offset: 3, page: page(3, 3) }]); - - expect(previous.pages).toHaveLength(1); - expect(previous.pageParams).toEqual([undefined]); - }); - - /** - * The offsets are computed before the fetch, so a page that landed in the - * meantime — the grid's own scroll lookahead — can already cover part of what - * comes back. Re-appending it would double every item in that range. - */ - it('trims a page that overlaps what is already loaded', () => { - const previous = { pages: [page(0, 5)], pageParams: [undefined] as Array }; - - const next = appendShellPages(previous, [{ offset: 3, page: page(3, 5) }]); - - expect(next.pages[1]?.items.map((i) => i.id)).toEqual(['i5', 'i6', 'i7']); - expect(countLoadedItems(next.pages)).toBe(8); - }); - - /** - * An upload between the read of the loaded count and the fetch shifts every - * offset by one. The grid keys its rows by the first item's id, so a repeated - * item is a duplicate React key, not just a duplicate tile. - */ - it('drops items the list already holds', () => { - const previous = { pages: [page(0, 5)], pageParams: [undefined] as Array }; - - const next = appendShellPages(previous, [ - { offset: 5, page: { ...page(4, 3), items: [{ id: 'i4' }, { id: 'i5' }, { id: 'i6' }] } }, - ]); - - expect(next.pages[1]?.items.map((i) => i.id)).toEqual(['i5', 'i6']); - }); - - /** - * Every item-index ↔ scroll-offset mapping in the scrollbar assumes the loaded - * items are the library's first N with nothing missing in between, so a page - * that would land past the end is dropped rather than appended into a gap. - */ - it('stops rather than appending past a hole', () => { - const previous = { pages: [page(0, 5)], pageParams: [undefined] as Array }; - - const next = appendShellPages(previous, [ - { offset: 5, page: page(5, 2) }, - { offset: 7, page: page(7, 2) }, - // A short page above left this one starting past the loaded end. - { offset: 11, page: page(11, 2) }, - ]); - - expect(next.pages).toHaveLength(3); - expect(countLoadedItems(next.pages)).toBe(9); - }); - - it('is a no-op when the batch is entirely already loaded', () => { - const previous = { pages: [page(0, 5)], pageParams: [undefined] as Array }; - - const next = appendShellPages(previous, [{ offset: 0, page: page(0, 5) }]); - - expect(next.pages).toHaveLength(1); - expect(countLoadedItems(next.pages)).toBe(5); +describe('countLoadedItems', () => { + it('sums items across pages', () => { + expect( + countLoadedItems([{ items: [1, 2, 3] }, { items: [] }, { items: [4, 5] }]), + ).toBe(5); }); }); diff --git a/frontend/src/lib/utils/shellPaging.ts b/frontend/src/lib/utils/shellPaging.ts index fd82e57..c0170b2 100644 --- a/frontend/src/lib/utils/shellPaging.ts +++ b/frontend/src/lib/utils/shellPaging.ts @@ -1,71 +1,65 @@ /** - * Page arithmetic for jumping the paginated gallery to an arbitrary item index. + * Page arithmetic for the windowed gallery. * - * The timeline scrollbar addresses the *whole* library (month counts from - * `/media/timeline`), while the grid only holds the pages fetched so far. A jump - * to a month below the loaded range therefore has to bring pages in before it can - * land. It used to do that one page per round trip, driven by `fetchNextPage`, and - * parked the viewport at the end of the loaded content while it waited — so the - * user saw the grid stop somewhere they had not asked for, scroll through each - * intervening page, and only then arrive. + * The gallery holds a *sliding window* of pages around the scroll position, not a + * prefix growing from the top. React Query's `maxPages` caps how many pages are + * retained and drops the far one as you scroll; these helpers cover the two things + * it does not: what page size the window tiles at, and — for a timeline jump that + * lands outside the window — which offsets to fetch to rebuild the window around a + * target item index. * - * The gap is now fetched by offset, several pages at a time, and the viewport does - * not move until the target row exists. These helpers are the pure part of that: - * which offsets are missing, and how a fetched batch merges into the cached pages. + * Everything here is addressed by *offset* (a plain item index), because the window + * can start anywhere: a jump to an old month makes "loaded" a middle slab, not the + * library's first N. Offsets are multiples of `SHELL_PAGE_SIZE`, so pages tile + * without gaps or overlap. */ /** - * Requested explicitly on every shell request rather than left to the server - * default, because offsets are only exact if every page holds the same count. - * The server caps at this value (`SHELL_PAGE_SIZE` in media.service.ts). + * Items per page. Small on purpose: the whole window (`WINDOW_PAGES` of these) is + * re-grouped and re-laid-out on every append, and every derived structure + * (groups, the id→index map, the justified layout) is held for the window. A large + * page made each of those O(page) allocations dwarf a screenful. The server caps + * at 2000 (`SHELL_PAGE_SIZE` in media.service.ts); anything at or under that is + * honored verbatim. */ -export const SHELL_PAGE_SIZE = 2000; +export const SHELL_PAGE_SIZE = 300; /** - * Pages one seek pulls before handing control back to React. - * - * A jump across a 100k library spans far more than this. Fetching it as several - * bounded batches renders what has arrived between them — so the "jumping" state - * visibly progresses — and keeps one mis-aimed click from queueing fifty requests. + * Pages retained at once — the window size, in pages. Passed to React Query's + * `maxPages`. Must be large enough to cover a viewport plus the scroll lookahead + * on both edges so the window is not refetched under the user mid-scroll, and + * small enough that the retained item count (`WINDOW_PAGES * SHELL_PAGE_SIZE`) + * stays bounded regardless of how far the library is scrolled. */ -export const MAX_PAGES_PER_SEEK = 8; +export const WINDOW_PAGES = 5; -/** In-flight shell requests per seek. Each one is a `skip`-heavy query. */ +/** In-flight shell requests per seek when rebuilding the window. */ export const SEEK_CONCURRENCY = 4; /** - * Offsets of the pages still needed for `targetIndex` to be loaded, in order, - * capped at `maxPages`. Empty when the target is already loaded. - * - * `loadedItems` is the count of contiguously loaded items — which is also the - * global offset of the first item not yet held, since pages run from index 0. + * Offsets of the pages a jump to `targetIndex` should load, so the target lands + * inside the window rather than at its very edge (one page of lead above it when + * the library allows). Always returns `WINDOW_PAGES` offsets; offsets past the end + * of the library come back as empty pages and are dropped by the caller. */ -export function missingPageOffsets( - loadedItems: number, +export function seekWindowOffsets( targetIndex: number, pageSize: number = SHELL_PAGE_SIZE, - maxPages: number = MAX_PAGES_PER_SEEK + windowPages: number = WINDOW_PAGES, ): number[] { - if (targetIndex < loadedItems || pageSize <= 0) return []; + if (targetIndex < 0 || pageSize <= 0 || windowPages <= 0) return []; + + const targetPage = Math.floor(targetIndex / pageSize); + // Keep one page above the target so scrolling back up a little does not + // immediately fall out of the window — but never below page 0. + const lead = Math.min(1, windowPages - 1); + const startPage = Math.max(0, targetPage - lead); - const needed = Math.ceil((targetIndex + 1 - loadedItems) / pageSize); const offsets: number[] = []; - for (let i = 0; i < Math.min(needed, maxPages); i++) { - offsets.push(loadedItems + i * pageSize); - } + for (let i = 0; i < windowPages; i++) offsets.push((startPage + i) * pageSize); return offsets; } -interface PageLike { - items: Array<{ id: string }>; - nextCursor: string | null; -} - -interface InfinitePages

{ - pages: P[]; - pageParams: Array; -} - /** Items held across all loaded pages. */ export function countLoadedItems(pages: Array<{ items: unknown[] }>): number { let total = 0; @@ -73,56 +67,6 @@ export function countLoadedItems(pages: Array<{ items: unknown[] }>): number { return total; } -/** - * Merge offset-fetched pages onto the end of the cached ones. - * - * Three things have to hold afterwards, and each is a rule below: - * - * - **No holes.** Every item-index ↔ scroll-offset mapping in the scrollbar - * assumes the loaded items are the library's first N. A page landing past the - * end of what is loaded is dropped rather than appended into a gap. - * - **No duplicates.** An insert between the read of `loadedItems` and the fetch - * shifts every offset by one, which would re-add rows the list already holds — - * and the grid keys rows by their first item's id. - * - **Replayable page params.** Each appended page records the cursor that would - * have produced it had the list been walked to, so React Query refetching the - * whole query reproduces the same list. - */ -export function appendShellPages

( - previous: InfinitePages

, - fetched: Array<{ offset: number; page: P }> -): InfinitePages

{ - const pages = [...previous.pages]; - const pageParams = [...previous.pageParams]; - - let loaded = countLoadedItems(pages); - - // Only the join needs guarding: a shift large enough to reach further back - // than the last page would mean the library changed out from under a jump by - // more than a page, which the next refetch resolves anyway. - const seen = new Set(); - for (const item of pages[pages.length - 1]?.items ?? []) seen.add(item.id); - - for (const { offset, page } of fetched) { - if (offset > loaded) break; - - const overlap = loaded - offset; - const fresh = (overlap > 0 ? page.items.slice(overlap) : page.items).filter( - (item) => !seen.has(item.id) - ); - if (fresh.length === 0) continue; - for (const item of fresh) seen.add(item.id); - - pageParams.push(pages[pages.length - 1]?.nextCursor ?? undefined); - // The spread widens `items` past P's own element type; the runtime shape is - // the page the server returned, minus rows the list already holds. - pages.push({ ...page, items: fresh } as P); - loaded += fresh.length; - } - - return { pages, pageParams }; -} - /** Run `fn` over `items`, at most `limit` at a time, preserving result order. */ export async function mapWithConcurrency( items: T[],