Reader: chunk-based image prefetch, disable browser scroll-restoration

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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-04-16 20:51:21 +08:00
parent 90f8f50166
commit 0ccb9debbb

View File

@ -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();