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 PREFETCH_NEXT_AT = 3;
|
||||||
const IMAGE_BATCH_RADIUS = 3;
|
const IMAGE_CHUNK_SIZE = 5;
|
||||||
|
const PREFETCH_LEAD = 3;
|
||||||
const DOUBLE_TAP_MS = 280;
|
const DOUBLE_TAP_MS = 280;
|
||||||
const KEEP_PREV_CHAPTER_PAGES = 5;
|
const KEEP_PREV_CHAPTER_PAGES = 5;
|
||||||
|
|
||||||
@ -134,15 +135,16 @@ export function PageReader({
|
|||||||
return m;
|
return m;
|
||||||
}, [chapters]);
|
}, [chapters]);
|
||||||
|
|
||||||
const fetchImagesAround = useCallback(
|
const fetchChunkFrom = useCallback(
|
||||||
async (chapterNum: number, pageNum: number) => {
|
async (chapterNum: number, startPage: number) => {
|
||||||
const meta = chapterMetasRef.current[chapterNum];
|
const meta = chapterMetasRef.current[chapterNum];
|
||||||
const chapter = chapterByNumber.get(chapterNum);
|
const chapter = chapterByNumber.get(chapterNum);
|
||||||
if (!meta || !chapter) return;
|
if (!meta || !chapter) return;
|
||||||
const start = Math.max(1, pageNum - IMAGE_BATCH_RADIUS);
|
const clamped = Math.max(1, startPage);
|
||||||
const end = Math.min(meta.length, pageNum + IMAGE_BATCH_RADIUS);
|
const endPage = Math.min(meta.length, clamped + IMAGE_CHUNK_SIZE - 1);
|
||||||
|
if (clamped > endPage) return;
|
||||||
const toFetch: number[] = [];
|
const toFetch: number[] = [];
|
||||||
for (let p = start; p <= end; p++) {
|
for (let p = clamped; p <= endPage; p++) {
|
||||||
const k = pageKey(chapterNum, p);
|
const k = pageKey(chapterNum, p);
|
||||||
if (imagesRef.current[k] || imagesInflightRef.current.has(k)) continue;
|
if (imagesRef.current[k] || imagesInflightRef.current.has(k)) continue;
|
||||||
imagesInflightRef.current.add(k);
|
imagesInflightRef.current.add(k);
|
||||||
@ -151,7 +153,7 @@ export function PageReader({
|
|||||||
if (toFetch.length === 0) return;
|
if (toFetch.length === 0) return;
|
||||||
const minP = toFetch[0];
|
const minP = toFetch[0];
|
||||||
const maxP = toFetch[toFetch.length - 1];
|
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.
|
// reuses the signal so chapter-unmount aborts them all in one shot.
|
||||||
let controller = radiusAbortRef.current.get(chapterNum);
|
let controller = radiusAbortRef.current.get(chapterNum);
|
||||||
if (!controller) {
|
if (!controller) {
|
||||||
@ -176,7 +178,7 @@ export function PageReader({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// aborted or failed — observer will re-trigger on next intersection
|
// aborted or failed — effect will re-fire when state changes
|
||||||
} finally {
|
} finally {
|
||||||
for (const p of toFetch)
|
for (const p of toFetch)
|
||||||
imagesInflightRef.current.delete(pageKey(chapterNum, p));
|
imagesInflightRef.current.delete(pageKey(chapterNum, p));
|
||||||
@ -185,6 +187,22 @@ export function PageReader({
|
|||||||
[chapterByNumber]
|
[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
|
// Tracked separately from imagesInflightRef so rapid taps dedup against
|
||||||
// each other but don't block on a slow radius fetch already in flight.
|
// each other but don't block on a slow radius fetch already in flight.
|
||||||
const forceFetchPage = useCallback(
|
const forceFetchPage = useCallback(
|
||||||
@ -248,7 +266,6 @@ export function PageReader({
|
|||||||
const key = pageKey(chNum, pNum);
|
const key = pageKey(chNum, pNum);
|
||||||
if (e.isIntersecting) {
|
if (e.isIntersecting) {
|
||||||
intersectingPagesRef.current.set(key, { chNum, pNum, el });
|
intersectingPagesRef.current.set(key, { chNum, pNum, el });
|
||||||
fetchImagesAround(chNum, pNum);
|
|
||||||
const chapter = chapterByNumber.get(chNum);
|
const chapter = chapterByNumber.get(chNum);
|
||||||
if (chapter && pNum >= chapter.totalPages - PREFETCH_NEXT_AT) {
|
if (chapter && pNum >= chapter.totalPages - PREFETCH_NEXT_AT) {
|
||||||
prefetchNextChapterMeta(chNum);
|
prefetchNextChapterMeta(chNum);
|
||||||
@ -298,11 +315,48 @@ export function PageReader({
|
|||||||
observerRef.current?.disconnect();
|
observerRef.current?.disconnect();
|
||||||
viewportObserverRef.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,
|
currentPageNum,
|
||||||
forceFetchPage,
|
currentChapterNum,
|
||||||
prefetchNextChapterMeta,
|
chapterMetas,
|
||||||
chapterByNumber,
|
images,
|
||||||
|
fetchChunkFrom,
|
||||||
|
cachedPageBounds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => {
|
const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => {
|
||||||
@ -331,6 +385,14 @@ export function PageReader({
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (resumeDoneRef.current) return;
|
if (resumeDoneRef.current) return;
|
||||||
resumeDoneRef.current = true;
|
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) =>
|
const instantTop = (top: number) =>
|
||||||
window.scrollTo({ top, behavior: "instant" as ScrollBehavior });
|
window.scrollTo({ top, behavior: "instant" as ScrollBehavior });
|
||||||
const shouldResume = resume || isPageReload();
|
const shouldResume = resume || isPageReload();
|
||||||
@ -441,6 +503,13 @@ export function PageReader({
|
|||||||
if (currentPageNum <= KEEP_PREV_CHAPTER_PAGES) {
|
if (currentPageNum <= KEEP_PREV_CHAPTER_PAGES) {
|
||||||
keep.add(currentChapterNum - 1);
|
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) {
|
for (const [ch, ctrl] of radiusAbortRef.current) {
|
||||||
if (!keep.has(ch)) {
|
if (!keep.has(ch)) {
|
||||||
ctrl.abort();
|
ctrl.abort();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user