From 0ccb9debbb618417e6dbcbf473a71d93c00b0b3c Mon Sep 17 00:00:00 2001 From: yiekheng Date: Thu, 16 Apr 2026 20:51:21 +0800 Subject: [PATCH] Reader: chunk-based image prefetch, disable browser scroll-restoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace radius-based sliding window with fixed 5-page chunks. On entering a chapter, fetch pages [current..current+4]. When user approaches within 3 pages of either the cached range's high or low edge, fetch the next forward or backward chunk. Near chapter end, also prefetch the next chapter's first chunk so the hand-off is seamless. Pruning now also keeps chapter+1 when user is in the last KEEP_PREV_CHAPTER_PAGES of current chapter — previously scrolling back from a just-entered chapter would prune it immediately even though the next forward scroll would re-fetch it. Also disable window.history.scrollRestoration on reader mount. On refresh while in an auto-appended chapter, the stored scrollY references a taller document than reloads with only the URL chapter — browser would clamp and land near the bottom. Manual mode lets the useLayoutEffect resume logic be the source of truth. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/PageReader.tsx | 95 +++++++++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 13 deletions(-) diff --git a/components/PageReader.tsx b/components/PageReader.tsx index 8bc7784..89884ba 100644 --- a/components/PageReader.tsx +++ b/components/PageReader.tsx @@ -41,7 +41,8 @@ type PageReaderProps = { }; const PREFETCH_NEXT_AT = 3; -const IMAGE_BATCH_RADIUS = 3; +const IMAGE_CHUNK_SIZE = 5; +const PREFETCH_LEAD = 3; const DOUBLE_TAP_MS = 280; const KEEP_PREV_CHAPTER_PAGES = 5; @@ -134,15 +135,16 @@ export function PageReader({ return m; }, [chapters]); - const fetchImagesAround = useCallback( - async (chapterNum: number, pageNum: number) => { + const fetchChunkFrom = useCallback( + async (chapterNum: number, startPage: number) => { const meta = chapterMetasRef.current[chapterNum]; const chapter = chapterByNumber.get(chapterNum); if (!meta || !chapter) return; - const start = Math.max(1, pageNum - IMAGE_BATCH_RADIUS); - const end = Math.min(meta.length, pageNum + IMAGE_BATCH_RADIUS); + const clamped = Math.max(1, startPage); + const endPage = Math.min(meta.length, clamped + IMAGE_CHUNK_SIZE - 1); + if (clamped > endPage) return; const toFetch: number[] = []; - for (let p = start; p <= end; p++) { + for (let p = clamped; p <= endPage; p++) { const k = pageKey(chapterNum, p); if (imagesRef.current[k] || imagesInflightRef.current.has(k)) continue; imagesInflightRef.current.add(k); @@ -151,7 +153,7 @@ export function PageReader({ if (toFetch.length === 0) return; const minP = toFetch[0]; const maxP = toFetch[toFetch.length - 1]; - // One controller per live chapter — every batch for this chapter + // One controller per live chapter — every fetch for this chapter // reuses the signal so chapter-unmount aborts them all in one shot. let controller = radiusAbortRef.current.get(chapterNum); if (!controller) { @@ -176,7 +178,7 @@ export function PageReader({ return next; }); } catch { - // aborted or failed — observer will re-trigger on next intersection + // aborted or failed — effect will re-fire when state changes } finally { for (const p of toFetch) imagesInflightRef.current.delete(pageKey(chapterNum, p)); @@ -185,6 +187,22 @@ export function PageReader({ [chapterByNumber] ); + const cachedPageBounds = useCallback( + (chapterNum: number): { min: number; max: number } => { + let min = Infinity; + let max = 0; + const prefix = `${chapterNum}-`; + for (const k of Object.keys(imagesRef.current)) { + if (!k.startsWith(prefix)) continue; + const p = Number(k.slice(prefix.length)); + if (p < min) min = p; + if (p > max) max = p; + } + return { min: min === Infinity ? 0 : min, max }; + }, + [] + ); + // Tracked separately from imagesInflightRef so rapid taps dedup against // each other but don't block on a slow radius fetch already in flight. const forceFetchPage = useCallback( @@ -248,7 +266,6 @@ export function PageReader({ const key = pageKey(chNum, pNum); if (e.isIntersecting) { intersectingPagesRef.current.set(key, { chNum, pNum, el }); - fetchImagesAround(chNum, pNum); const chapter = chapterByNumber.get(chNum); if (chapter && pNum >= chapter.totalPages - PREFETCH_NEXT_AT) { prefetchNextChapterMeta(chNum); @@ -298,11 +315,48 @@ export function PageReader({ observerRef.current?.disconnect(); viewportObserverRef.current?.disconnect(); }; + }, [forceFetchPage, prefetchNextChapterMeta, chapterByNumber]); + + // Chunk prefetch trigger — runs on current page/chapter change or when + // images state changes. Maintains bidirectional sliding chunks of + // IMAGE_CHUNK_SIZE pages each direction when user approaches within + // PREFETCH_LEAD of the cached range edges. + useEffect(() => { + const ch = currentChapterNum; + const meta = chapterMetas[ch]; + if (!meta) return; + const { min, max } = cachedPageBounds(ch); + if (max === 0) { + // Nothing cached yet for this chapter — seed chunk from current page + fetchChunkFrom(ch, currentPageNum); + return; + } + if (currentPageNum + PREFETCH_LEAD >= max && max < meta.length) { + fetchChunkFrom(ch, max + 1); + } + if (currentPageNum - PREFETCH_LEAD <= min && min > 1) { + fetchChunkFrom(ch, Math.max(1, min - IMAGE_CHUNK_SIZE)); + } + // Near chapter end — prefetch next chapter's first chunk of images + // (meta is prefetched separately by the observer). Keeps the hand-off + // seamless instead of waiting for chapter boundary to trigger a cold + // fetch. + if (currentPageNum + PREFETCH_LEAD >= meta.length) { + const nextMeta = chapterMetas[ch + 1]; + if (nextMeta) { + const nextBounds = cachedPageBounds(ch + 1); + if (nextBounds.max === 0) { + fetchChunkFrom(ch + 1, 1); + } + } + } }, [ - fetchImagesAround, - forceFetchPage, - prefetchNextChapterMeta, - chapterByNumber, + currentPageNum, + currentChapterNum, + chapterMetas, + images, + fetchChunkFrom, + cachedPageBounds, ]); const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => { @@ -331,6 +385,14 @@ export function PageReader({ useLayoutEffect(() => { if (resumeDoneRef.current) return; resumeDoneRef.current = true; + // Disable browser auto scroll-restoration. On refresh while in an + // auto-appended chapter, the stored scrollY references a taller + // document than what reloads with only the URL's chapter — browser + // would clamp to scrollHeight and land near the bottom. Manual mode + // lets our own resume logic below be the source of truth. + if ("scrollRestoration" in window.history) { + window.history.scrollRestoration = "manual"; + } const instantTop = (top: number) => window.scrollTo({ top, behavior: "instant" as ScrollBehavior }); const shouldResume = resume || isPageReload(); @@ -441,6 +503,13 @@ export function PageReader({ if (currentPageNum <= KEEP_PREV_CHAPTER_PAGES) { keep.add(currentChapterNum - 1); } + const curMeta = chapterMetasRef.current[currentChapterNum]; + if ( + curMeta && + currentPageNum >= curMeta.length - KEEP_PREV_CHAPTER_PAGES + 1 + ) { + keep.add(currentChapterNum + 1); + } for (const [ch, ctrl] of radiusAbortRef.current) { if (!keep.has(ch)) { ctrl.abort();