From 3745f1f3166347cccc1f0221001ca2290980915a Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 12 Apr 2026 11:00:01 +0800 Subject: [PATCH] Add backward prefetch for resumed mid-chapter reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user resumes at a mid-chapter page, the reader previously skipped the earlier pages entirely. This adds a scroll-up prefetch so those earlier pages appear smoothly as the user scrolls toward the top. - prependBatch() mirrors fetchBatch but decrements the offset cursor and prepends the new pages. prependExhaustedRef fires when the cursor hits 0 (start of chapter). - Trigger: scrollY < 2500px fires prepend — well before the user reaches the top, so the DOM + images spawn ahead of the scroll position. - Scroll preservation: aspect-[3/4] on reserves vertical space before the image bytes arrive, so scrollHeight is accurate immediately after React commits. A single scrollBy(delta) keeps the previously-visible page visually anchored — no per-image jitter. - Forward-fetch trigger indices + loadedCountRef are shifted by batch.length on each prepend so next-batch prefetch still fires at the correct page. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/PageReader.tsx | 87 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/components/PageReader.tsx b/components/PageReader.tsx index dea9b6a..9270a2f 100644 --- a/components/PageReader.tsx +++ b/components/PageReader.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { @@ -84,6 +84,13 @@ export function PageReader({ const observerRef = useRef(null); const pageRefsRef = useRef>(new Map()); + // Reverse-fetch cursor: the offset of the first loaded page of the + // starting chapter. Used to prepend previous pages when the user scrolls + // up from a resumed mid-chapter position. + const prependOffsetRef = useRef(offsetRef.current); + const prependLoadingRef = useRef(false); + const prependExhaustedRef = useRef(offsetRef.current === 0); + const advanceChapterOrFinish = useCallback(() => { if (fetchChapterIdxRef.current + 1 < chapters.length) { fetchChapterIdxRef.current += 1; @@ -93,6 +100,71 @@ export function PageReader({ } }, [chapters.length]); + const startChapter = useMemo( + () => chapters.find((c) => c.number === startChapterNumber), + [chapters, startChapterNumber] + ); + + const prependBatch = useCallback(async () => { + if ( + prependLoadingRef.current || + prependExhaustedRef.current || + !startChapter + ) + return; + const currentOffset = prependOffsetRef.current; + const newOffset = Math.max(0, currentOffset - BATCH_SIZE); + const limit = currentOffset - newOffset; + if (limit <= 0) { + prependExhaustedRef.current = true; + return; + } + prependLoadingRef.current = true; + const beforeHeight = document.documentElement.scrollHeight; + try { + const res = await fetch( + `/api/pages?chapterId=${startChapter.id}&offset=${newOffset}&limit=${limit}` + ); + const batch: RawPage[] = await res.json(); + if (batch.length === 0) { + prependExhaustedRef.current = true; + return; + } + prependOffsetRef.current = newOffset; + if (newOffset === 0) prependExhaustedRef.current = true; + + // Shift forward-fetch trigger indices + total count since every + // already-loaded page has moved right by batch.length + const shift = batch.length; + const shifted = new Set(); + for (const t of triggerIndicesRef.current) shifted.add(t + shift); + triggerIndicesRef.current = shifted; + loadedCountRef.current += shift; + + setPages((prev) => [ + ...batch.map((p) => ({ + chapterNumber: startChapter.number, + pageNumber: p.number, + imageUrl: p.imageUrl, + })), + ...prev, + ]); + + // aspect-[3/4] on the prepended s reserves height before their + // bytes arrive, so scrollHeight is accurate immediately after React + // commits. One scrollBy keeps the previously-visible page anchored. + requestAnimationFrame(() => { + const afterHeight = document.documentElement.scrollHeight; + const delta = afterHeight - beforeHeight; + if (delta > 0) window.scrollBy({ top: delta }); + }); + } catch { + // ignore — user can scroll-up again to retry + } finally { + prependLoadingRef.current = false; + } + }, [startChapter]); + const fetchBatch = useCallback(async () => { if (loadingRef.current || doneRef.current) return; const chapter = chapters[fetchChapterIdxRef.current]; @@ -253,6 +325,15 @@ export function PageReader({ hiddenByScrollRef.current = true; setShowUI(false); } + // Trigger backward prepend when user approaches the top — fire early + // so the DOM/pics are already spawned by the time the user scrolls there + if ( + y < 2500 && + !prependLoadingRef.current && + !prependExhaustedRef.current + ) { + prependBatch(); + } // Nothing loaded yet — don't overwrite the resumed-page state if (pages.length === 0) return; let chapter = pages[0].chapterNumber; @@ -277,7 +358,7 @@ export function PageReader({ window.removeEventListener("scroll", onScroll); if (rafId) cancelAnimationFrame(rafId); }; - }, [pages, startChapterNumber]); + }, [pages, startChapterNumber, prependBatch]); // Persist progress as user scrolls. offsetRef has been pre-seeded above // so the first fetched page IS the resumed page — no scroll restoration @@ -377,7 +458,7 @@ export function PageReader({ {`Page