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:
parent
90f8f50166
commit
0ccb9debbb
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user