PDF pages rendered blank while scrolling in scrolled mode (#4795, resurfacing #4031). On-device profiling showed each page takes hundreds of ms to render while the preload margin gave only about half a page of lead, and loads were unbounded and unprioritized. Bump the foliate-js submodule to widen the scrolled-mode preload margin and drive page loading through a bounded, viewport-prioritized scheduler (readest/foliate-js#40). Adds unit coverage for the new planScrollModePages scheduler. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { planScrollModePages } from 'foliate-js/fixed-layout.js';
|
||||
|
||||
// Page model the scheduler operates on. `visible` is set by the
|
||||
// IntersectionObserver (true when the page is within the widened preload
|
||||
// margin); `state` mirrors the load lifecycle.
|
||||
const page = (index: number, state: string, visible: boolean) => ({ index, state, visible });
|
||||
|
||||
describe('planScrollModePages', () => {
|
||||
it('loads the nearest visible idle pages first, within the concurrency budget', () => {
|
||||
const pages = [
|
||||
page(8, 'idle', true),
|
||||
page(9, 'idle', true),
|
||||
page(10, 'loaded', true),
|
||||
page(11, 'idle', true),
|
||||
page(12, 'idle', true),
|
||||
];
|
||||
|
||||
const { load } = planScrollModePages({
|
||||
pages,
|
||||
currentIndex: 10,
|
||||
maxLoaded: 8,
|
||||
maxConcurrent: 2,
|
||||
loadingCount: 0,
|
||||
});
|
||||
|
||||
// Nearest to index 10 are 9 and 11; both idle+visible; budget is 2.
|
||||
expect(load).toEqual([9, 11]);
|
||||
});
|
||||
|
||||
it('subtracts in-flight loads from the concurrency budget', () => {
|
||||
const pages = [page(9, 'idle', true), page(11, 'idle', true), page(12, 'idle', true)];
|
||||
|
||||
const { load } = planScrollModePages({
|
||||
pages,
|
||||
currentIndex: 10,
|
||||
maxLoaded: 8,
|
||||
maxConcurrent: 3,
|
||||
loadingCount: 2,
|
||||
});
|
||||
|
||||
// 3 concurrent allowed, 2 already loading -> budget of 1 -> nearest only.
|
||||
expect(load).toEqual([9]);
|
||||
});
|
||||
|
||||
it('never starts a load when the budget is already spent', () => {
|
||||
const pages = [page(9, 'idle', true), page(11, 'idle', true)];
|
||||
|
||||
const { load } = planScrollModePages({
|
||||
pages,
|
||||
currentIndex: 10,
|
||||
maxLoaded: 8,
|
||||
maxConcurrent: 2,
|
||||
loadingCount: 2,
|
||||
});
|
||||
|
||||
expect(load).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores idle pages outside the visible preload band', () => {
|
||||
const pages = [page(9, 'idle', false), page(11, 'idle', true)];
|
||||
|
||||
const { load } = planScrollModePages({
|
||||
pages,
|
||||
currentIndex: 10,
|
||||
maxLoaded: 8,
|
||||
maxConcurrent: 4,
|
||||
loadingCount: 0,
|
||||
});
|
||||
|
||||
// Page 9 is not visible, so only the visible page 11 loads.
|
||||
expect(load).toEqual([11]);
|
||||
});
|
||||
|
||||
it('does not re-load pages that are already loading or loaded', () => {
|
||||
const pages = [page(9, 'loading', true), page(10, 'loaded', true), page(11, 'idle', true)];
|
||||
|
||||
const { load } = planScrollModePages({
|
||||
pages,
|
||||
currentIndex: 10,
|
||||
maxLoaded: 8,
|
||||
maxConcurrent: 4,
|
||||
loadingCount: 1,
|
||||
});
|
||||
|
||||
expect(load).toEqual([11]);
|
||||
});
|
||||
|
||||
it('never re-loads a page in the terminal error state', () => {
|
||||
// A page whose load failed is parked in 'error' so the post-completion
|
||||
// reschedule cannot retry it forever in a tight async loop.
|
||||
const pages = [page(10, 'error', true), page(11, 'idle', true)];
|
||||
|
||||
const { load } = planScrollModePages({
|
||||
pages,
|
||||
currentIndex: 10,
|
||||
maxLoaded: 8,
|
||||
maxConcurrent: 4,
|
||||
loadingCount: 0,
|
||||
});
|
||||
|
||||
expect(load).toEqual([11]);
|
||||
});
|
||||
|
||||
it('evicts the farthest non-visible loaded pages once over the in-memory cap', () => {
|
||||
const pages = [
|
||||
page(2, 'loaded', false),
|
||||
page(20, 'loaded', false),
|
||||
page(9, 'loaded', true),
|
||||
page(10, 'loaded', true),
|
||||
page(11, 'loaded', true),
|
||||
];
|
||||
|
||||
const { evict } = planScrollModePages({
|
||||
pages,
|
||||
currentIndex: 10,
|
||||
maxLoaded: 3,
|
||||
maxConcurrent: 2,
|
||||
loadingCount: 0,
|
||||
});
|
||||
|
||||
// 5 loaded, cap 3 -> evict the 2 farthest from index 10: pages 20 and 2.
|
||||
expect(evict.sort((a: number, b: number) => a - b)).toEqual([2, 20]);
|
||||
});
|
||||
|
||||
it('never evicts a visible page even when over the cap', () => {
|
||||
const pages = [page(9, 'loaded', true), page(10, 'loaded', true), page(11, 'loaded', true)];
|
||||
|
||||
const { evict } = planScrollModePages({
|
||||
pages,
|
||||
currentIndex: 10,
|
||||
maxLoaded: 1,
|
||||
maxConcurrent: 2,
|
||||
loadingCount: 0,
|
||||
});
|
||||
|
||||
// All three are on screen; none may be torn out from under the reader.
|
||||
expect(evict).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not evict when loaded pages are within the cap', () => {
|
||||
const pages = [page(9, 'loaded', true), page(10, 'loaded', true)];
|
||||
|
||||
const { evict } = planScrollModePages({
|
||||
pages,
|
||||
currentIndex: 10,
|
||||
maxLoaded: 8,
|
||||
maxConcurrent: 2,
|
||||
loadingCount: 0,
|
||||
});
|
||||
|
||||
expect(evict).toEqual([]);
|
||||
});
|
||||
});
|
||||
+1
-1
Submodule packages/foliate-js updated: 15fc999f85...6f1a190184
Reference in New Issue
Block a user