merge: fix/42-reader-mobile-slow-load dans develop (préchargeur CBZ vertical priorisé, concurrency 3 + fenêtre profonde, fin de la saturation réseau Firefox mobile — 4e correctif #42)

This commit is contained in:
Git Agent
2026-08-27 18:06:55 +02:00
2 changed files with 152 additions and 45 deletions

View File

@ -6,12 +6,74 @@ import { majorityVisiblePage, type ReaderMode } from "./readerScroll";
import type { ReaderControls } from "./ReaderShell"; import type { ReaderControls } from "./ReaderShell";
type PageCommitStrategy = "immediate" | "queued"; type PageCommitStrategy = "immediate" | "queued";
type PreloadedComicImage = {
image: HTMLImageElement;
status: "loading" | "loaded" | "error";
};
type PreloadPriority = "adjacent" | "deep";
type VerticalPreloadScheduler = {
active: number;
generation: number;
timer: number | null;
queuedPages: Set<number>;
queues: Record<PreloadPriority, number[]>;
};
const VERTICAL_ANCHOR_TOLERANCE_PX = 24; const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
const VERTICAL_ANCHOR_STABLE_FRAMES = 18; const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180; const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180; const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180;
const VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 24; const VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3;
const VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100;
const VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90;
const VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD = [0, 1, -1, 2, -2, 3, -3, 4, -4];
const VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"];
function scheduleReaderFrame(callback: FrameRequestCallback) {
if (typeof requestAnimationFrame === "function") return requestAnimationFrame(callback);
callback(0);
return 0;
}
function cancelReaderFrame(frameId: number) {
if (frameId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frameId);
}
function createVerticalPreloadScheduler(): VerticalPreloadScheduler {
return {
active: 0,
generation: 0,
timer: null,
queuedPages: new Set<number>(),
queues: {
adjacent: [],
deep: []
}
};
}
function uniqueReaderPages(pages: Array<number | null>, currentPage: number, pageCount: number) {
const seen = new Set<number>();
return pages
.map((candidate) => (candidate ? clampReaderPage(candidate, pageCount) : null))
.filter((candidate): candidate is number => {
if (!candidate || candidate === currentPage || seen.has(candidate)) return false;
seen.add(candidate);
return true;
});
}
function verticalImagePreloadPlan(currentPage: number, pageCount: number) {
const deepCenter = clampReaderPage(currentPage + VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET, pageCount);
return {
adjacent: uniqueReaderPages([currentPage - 1, currentPage + 1, currentPage + 2], currentPage, pageCount),
deep: uniqueReaderPages(
VERTICAL_IMAGE_PRELOAD_DEEP_SPREAD.map((offset) => deepCenter + offset),
currentPage,
pageCount
)
};
}
export function CbzReader({ export function CbzReader({
bookId, bookId,
@ -40,7 +102,8 @@ export function CbzReader({
const verticalInitialSyncDoneRef = useRef(false); const verticalInitialSyncDoneRef = useRef(false);
const verticalAnchorAttemptRef = useRef(0); const verticalAnchorAttemptRef = useRef(0);
const verticalAnchorStableFramesRef = useRef(0); const verticalAnchorStableFramesRef = useRef(0);
const preloadedImagesRef = useRef(new Map<number, HTMLImageElement>()); const preloadedImagesRef = useRef(new Map<number, PreloadedComicImage>());
const preloadSchedulerRef = useRef(createVerticalPreloadScheduler());
const pendingImageSizesRef = useRef<Record<number, ReaderSize>>({}); const pendingImageSizesRef = useRef<Record<number, ReaderSize>>({});
const imageSizeFlushFrameRef = useRef<number | null>(null); const imageSizeFlushFrameRef = useRef<number | null>(null);
const [pages, setPages] = useState<CbzPagesDto | null>(null); const [pages, setPages] = useState<CbzPagesDto | null>(null);
@ -98,7 +161,7 @@ export function CbzReader({
const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => { const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => {
pendingImageSizesRef.current[pageNumber] = size; pendingImageSizesRef.current[pageNumber] = size;
if (imageSizeFlushFrameRef.current !== null) return; if (imageSizeFlushFrameRef.current !== null) return;
imageSizeFlushFrameRef.current = requestAnimationFrame(() => { imageSizeFlushFrameRef.current = scheduleReaderFrame(() => {
imageSizeFlushFrameRef.current = null; imageSizeFlushFrameRef.current = null;
const pending = pendingImageSizesRef.current; const pending = pendingImageSizesRef.current;
pendingImageSizesRef.current = {}; pendingImageSizesRef.current = {};
@ -116,6 +179,70 @@ export function CbzReader({
}); });
}); });
}, []); }, []);
const drainVerticalPreloadQueue = useCallback(() => {
const scheduler = preloadSchedulerRef.current;
scheduler.timer = null;
if (typeof Image === "undefined") return;
const nextPage = () => {
for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) {
const pageNumber = scheduler.queues[priority].shift();
if (pageNumber) {
scheduler.queuedPages.delete(pageNumber);
return pageNumber;
}
}
return null;
};
while (scheduler.active < VERTICAL_IMAGE_PRELOAD_CONCURRENCY) {
const pageNumber = nextPage();
if (!pageNumber) return;
if (preloadedImagesRef.current.has(pageNumber)) continue;
const generation = scheduler.generation;
const image = new Image();
preloadedImagesRef.current.set(pageNumber, { image, status: "loading" });
scheduler.active += 1;
image.decoding = "async";
image.loading = "eager";
image.onload = () => {
if (generation !== scheduler.generation) return;
scheduler.active -= 1;
preloadedImagesRef.current.set(pageNumber, { image, status: "loaded" });
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
queueImageSize(pageNumber, { width: image.naturalWidth, height: image.naturalHeight });
}
drainVerticalPreloadQueue();
};
image.onerror = () => {
if (generation !== scheduler.generation) return;
scheduler.active -= 1;
preloadedImagesRef.current.set(pageNumber, { image, status: "error" });
drainVerticalPreloadQueue();
};
image.src = api.cbzPageUrl(bookId, pageNumber);
}
}, [bookId, queueImageSize]);
const enqueueVerticalPreload = useCallback(
(plan: Record<PreloadPriority, number[]>) => {
const scheduler = preloadSchedulerRef.current;
for (const priority of VERTICAL_IMAGE_PRELOAD_PRIORITIES) {
if (priority === "deep") {
for (const pageNumber of scheduler.queues.deep) scheduler.queuedPages.delete(pageNumber);
scheduler.queues.deep = [];
}
for (const pageNumber of plan[priority]) {
if (preloadedImagesRef.current.has(pageNumber) || scheduler.queuedPages.has(pageNumber)) continue;
scheduler.queuedPages.add(pageNumber);
scheduler.queues[priority].push(pageNumber);
}
}
if (scheduler.timer !== null || scheduler.queuedPages.size === 0) return;
scheduler.timer = window.setTimeout(drainVerticalPreloadQueue, VERTICAL_IMAGE_PRELOAD_DELAY_MS);
},
[drainVerticalPreloadQueue]
);
const commitVisiblePage = useCallback( const commitVisiblePage = useCallback(
(stage: HTMLElement, allowInitialCommit = false) => { (stage: HTMLElement, allowInitialCommit = false) => {
if (!pages || !verticalTrackingReadyRef.current) return; if (!pages || !verticalTrackingReadyRef.current) return;
@ -219,46 +346,16 @@ export function CbzReader({
setImageSizes({}); setImageSizes({});
pendingImageSizesRef.current = {}; pendingImageSizesRef.current = {};
preloadedImagesRef.current.clear(); preloadedImagesRef.current.clear();
const scheduler = preloadSchedulerRef.current;
if (scheduler.timer !== null) window.clearTimeout(scheduler.timer);
preloadSchedulerRef.current = createVerticalPreloadScheduler();
preloadSchedulerRef.current.generation = scheduler.generation + 1;
}, [bookId, retryAttempt]); }, [bookId, retryAttempt]);
useEffect(() => { useEffect(() => {
if (mode !== "vertical" || !pages || typeof Image === "undefined") return; if (mode !== "vertical" || !pages || typeof Image === "undefined" || !verticalUserScrollRef.current) return;
let cancelled = false; enqueueVerticalPreload(verticalImagePreloadPlan(currentPage, pages.pageCount));
let nextIndex = 0; }, [currentPage, enqueueVerticalPreload, mode, pages, retryAttempt]);
let active = 0;
const pageItems = pages.pages;
const preloadNext = () => {
if (cancelled) return;
while (active < VERTICAL_IMAGE_PRELOAD_CONCURRENCY && nextIndex < pageItems.length) {
const item = pageItems[nextIndex];
nextIndex += 1;
if (preloadedImagesRef.current.has(item.page)) continue;
const image = new Image();
preloadedImagesRef.current.set(item.page, image);
active += 1;
image.decoding = "async";
image.loading = "eager";
image.onload = () => {
active -= 1;
if (!cancelled && image.naturalWidth > 0 && image.naturalHeight > 0) {
queueImageSize(item.page, { width: image.naturalWidth, height: image.naturalHeight });
}
preloadNext();
};
image.onerror = () => {
active -= 1;
preloadNext();
};
image.src = api.cbzPageUrl(bookId, item.page);
}
};
preloadNext();
return () => {
cancelled = true;
};
}, [bookId, mode, pages, queueImageSize, retryAttempt]);
useEffect(() => { useEffect(() => {
const frame = frameRef.current; const frame = frameRef.current;
@ -366,7 +463,7 @@ export function CbzReader({
useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]); useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]);
useEffect( useEffect(
() => () => { () => () => {
if (imageSizeFlushFrameRef.current !== null) cancelAnimationFrame(imageSizeFlushFrameRef.current); if (imageSizeFlushFrameRef.current !== null) cancelReaderFrame(imageSizeFlushFrameRef.current);
}, },
[] []
); );
@ -421,10 +518,12 @@ export function CbzReader({
<img <img
src={api.cbzPageUrl(bookId, item.page)} src={api.cbzPageUrl(bookId, item.page)}
alt={item.name} alt={item.name}
loading="lazy" fetchPriority={item.page === currentPage ? "high" : "auto"}
loading={item.page === currentPage ? "eager" : "lazy"}
style={verticalImageStyle(item.page)} style={verticalImageStyle(item.page)}
onLoad={(event) => { onLoad={(event) => {
const { naturalWidth, naturalHeight } = event.currentTarget; const { naturalWidth, naturalHeight } = event.currentTarget;
preloadedImagesRef.current.set(item.page, { image: event.currentTarget, status: "loaded" });
queueImageSize(item.page, { width: naturalWidth, height: naturalHeight }); queueImageSize(item.page, { width: naturalWidth, height: naturalHeight });
}} }}
onError={() => setPageError(`Page ${item.page} indisponible.`)} onError={() => setPageError(`Page ${item.page} indisponible.`)}

View File

@ -222,12 +222,20 @@ describe("reader runtime helpers", () => {
expect(verticalBranch).not.toContain('hidden={!imageSizes[item.page]}'); expect(verticalBranch).not.toContain('hidden={!imageSizes[item.page]}');
}); });
it("preloads deep CBZ vertical pages without relying only on browser lazy loading", () => { it("keeps CBZ vertical image loading on the rendered page elements", () => {
const source = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8"); const source = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8");
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_CONCURRENCY"); expect(source).toContain("VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 3");
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_DELAY_MS = 100");
expect(source).toContain("VERTICAL_IMAGE_PRELOAD_DEEP_OFFSET = 90");
expect(source).toContain('VERTICAL_IMAGE_PRELOAD_PRIORITIES: PreloadPriority[] = ["adjacent", "deep"]');
expect(source).toContain("scheduler.queues.deep = []");
expect(source).toContain("!verticalUserScrollRef.current");
expect(source).toContain("preloadSchedulerRef");
expect(source).toContain("verticalImagePreloadPlan");
expect(source).toContain("new Image()"); expect(source).toContain("new Image()");
expect(source).toContain('image.loading = "eager"'); expect(source).toContain('fetchPriority={item.page === currentPage ? "high" : "auto"}');
expect(source).toContain('loading={item.page === currentPage ? "eager" : "lazy"}');
expect(source).toContain("queueImageSize"); expect(source).toContain("queueImageSize");
expect(source).toContain("if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true"); expect(source).toContain("if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true");
}); });