From 580c5e5deb5a993e0445e9f46dbc4fece967ec7a Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 27 Jun 2026 11:02:16 +0800 Subject: [PATCH] fix(reader): eliminate PDF scrolled-mode rendering lag on mobile (#4795) (#4813) 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) --- .../fixed-layout-scroll-scheduler.test.ts | 155 ++++++++++++++++++ packages/foliate-js | 2 +- 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/src/__tests__/document/fixed-layout-scroll-scheduler.test.ts diff --git a/apps/readest-app/src/__tests__/document/fixed-layout-scroll-scheduler.test.ts b/apps/readest-app/src/__tests__/document/fixed-layout-scroll-scheduler.test.ts new file mode 100644 index 00000000..ce91fd68 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/fixed-layout-scroll-scheduler.test.ts @@ -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([]); + }); +}); diff --git a/packages/foliate-js b/packages/foliate-js index 15fc999f..6f1a1901 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 15fc999f854a41ed6111b8d57c7f25fb97693419 +Subproject commit 6f1a190184802457f38a9e75a8111fc50e19e068