From a0227f98e2b32abdf48bcbfaac7ff2ac0594b384 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 27 Jun 2026 10:26:37 +0800 Subject: [PATCH] perf(reader): stop per-frame background reflow on swipe page turns (#4785) (#4814) Fixes readest/readest#4785 Swipe page turns dropped frames, worst when crossing .xhtml section boundaries. The paginator's #replaceBackground rebuilt its whole paint context every animation frame (a getComputedStyle plus one getBoundingClientRect per rendered view), and that per-frame cost scales with the number of loaded views, which peaks at a boundary where adjacent sections are preloaded. Bumps the foliate-js submodule to 15fc999 (readest/foliate-js#41) to snapshot the paint context once per gesture and reuse it on every frame, and to defer the heavy mid-drag section preload off the active drag. The bump also advances foliate-js to current main, picking up the gpu-composite page-turn opt-in (readest/foliate-js#39). Adds a real-browser test that drives an animated turn and a synthetic drag and asserts the section computed style is read a small constant number of times instead of once per frame (snap 39 -> <=3, drag 7 -> <=1). Co-authored-by: Claude Opus 4.8 (1M context) --- ...nator-background-anim-perf.browser.test.ts | 226 ++++++++++++++++++ packages/foliate-js | 2 +- 2 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/src/__tests__/document/paginator-background-anim-perf.browser.test.ts diff --git a/apps/readest-app/src/__tests__/document/paginator-background-anim-perf.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-background-anim-perf.browser.test.ts new file mode 100644 index 00000000..645fb2d5 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/paginator-background-anim-perf.browser.test.ts @@ -0,0 +1,226 @@ +// Performance regression test for the swipe page-turn frame drops at chapter +// boundaries (readest/readest#4785). +// +// In paginated mode the paginator keeps the per-section backgrounds glued to the +// content during an animated page turn by repainting them every animation frame +// (the #replaceBackground / syncBackground loop). The original implementation +// rebuilt the whole paint *context* every frame: a getComputedStyle() on the +// primary section's plus one getBoundingClientRect() per rendered view. +// Those are forced style/layout reads, and their cost scales with the number of +// loaded views — which peaks exactly when adjacent sections are preloaded at a +// chapter boundary, so the swipe animation dropped frames there. +// +// Everything that read context is invariant for the duration of a single scroll +// animation (theme/texture, the background+container geometry, each view's size +// and resolved background) — only the scroll offset changes. So the context must +// be snapshotted once when the animation starts and reused every frame; the +// per-frame work must NOT re-run getComputedStyle on the section document. +// +// This test drives a real animated turn and asserts the primary section's +// getComputedStyle is read at most a small constant number of times across the +// whole turn (not once per frame). +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc } from '@/libs/document'; +import type { Renderer } from '@/types/view'; + +const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href; + +let book: BookDoc; + +const loadEPUB = async () => { + const resp = await fetch(EPUB_URL); + const buffer = await resp.arrayBuffer(); + const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' }); + const loader = new DocumentLoader(file); + const { book } = await loader.open(); + return book; +}; + +const waitForStabilized = (el: HTMLElement, timeout = 10000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout); + el.addEventListener( + 'stabilized', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +const waitForFillComplete = async (el: Renderer, timeout = 10000) => { + const start = Date.now(); + let lastCount = -1; + let stableFor = 0; + while (Date.now() - start < timeout) { + const count = el.getContents().length; + if (count === lastCount) { + stableFor += 100; + if (stableFor >= 500) return; + } else { + stableFor = 0; + lastCount = count; + } + await new Promise((r) => setTimeout(r, 100)); + } +}; + +describe('Paginator animated background repaint (browser)', () => { + let paginator: Renderer; + + const suppressHandler = (e: ErrorEvent) => { + if (e.message?.includes('getComputedStyle')) e.preventDefault(); + }; + + beforeAll(async () => { + window.addEventListener('error', suppressHandler); + book = await loadEPUB(); + await import('foliate-js/paginator.js'); + }, 30000); + + afterAll(() => { + window.removeEventListener('error', suppressHandler); + }); + + const createPaginator = () => { + const el = document.createElement('foliate-paginator') as Renderer; + Object.assign(el.style, { + width: '800px', + height: '600px', + position: 'absolute', + left: '0', + top: '0', + }); + // Enable the snap/smooth animation path (syncBackground per-frame loop). + el.setAttribute('animated', ''); + document.body.appendChild(el); + return el; + }; + + afterEach(async () => { + if (paginator) { + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + try { + paginator.destroy(); + } catch { + /* iframe body may already be torn down */ + } + paginator.remove(); + } + }); + + it('snapshots the paint context once per animated turn instead of every frame', async () => { + // A multi-page section so next() animates a page turn that stays within the + // section (the within-section snap/smooth path runs the per-frame loop). + const longIdx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 4000); + const idx = longIdx >= 0 ? longIdx : book.sections!.findIndex((s) => s.linear !== 'no'); + + paginator = createPaginator(); + paginator.open(book); + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: idx }); + await stabilized; + await waitForFillComplete(paginator); + + const primary = paginator.getContents().find((c) => c.index === paginator.primaryIndex); + expect(primary).toBeDefined(); + const doc = primary!.doc; + const win = doc.defaultView as Window & typeof globalThis; + const htmlEl = doc.documentElement; + + // Count getComputedStyle reads against the primary section's — the + // forced style read #replaceBackground performs to resolve the theme/texture + // background. The per-frame paint must not repeat it. + let htmlStyleReads = 0; + const origGetComputedStyle = win.getComputedStyle.bind(win); + win.getComputedStyle = ((element: Element, pseudo?: string | null) => { + if (element === htmlEl) htmlStyleReads++; + return origGetComputedStyle(element, pseudo ?? undefined); + }) as typeof win.getComputedStyle; + + try { + await paginator.next(); + // Let any trailing syncBackground RAF frames flush. + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + } finally { + win.getComputedStyle = origGetComputedStyle; + } + + // A 300ms animation at ~60fps repaints ~18 frames. The pre-fix code read the + // computed style on every one of those frames; the fixed code reads it + // a small constant number of times (context build + settle repaints). + expect(htmlStyleReads).toBeLessThanOrEqual(3); + }); + + it('reuses the drag snapshot across swipe-drag frames instead of rebuilding', async () => { + const longIdx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 4000); + const idx = longIdx >= 0 ? longIdx : book.sections!.findIndex((s) => s.linear !== 'no'); + + paginator = createPaginator(); + paginator.open(book); + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: idx }); + await stabilized; + await waitForFillComplete(paginator); + + const primary = paginator.getContents().find((c) => c.index === paginator.primaryIndex); + expect(primary).toBeDefined(); + const doc = primary!.doc; + const win = doc.defaultView as Window & typeof globalThis; + const htmlEl = doc.documentElement; + + const nextFrame = () => + new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); + + const mkTouch = (x: number) => + new Touch({ + identifier: 1, + target: paginator, + screenX: x, + screenY: 100, + clientX: x, + clientY: 100, + }); + const mkEvent = (type: string, x: number) => + new TouchEvent(type, { + bubbles: true, + cancelable: true, + changedTouches: [mkTouch(x)], + touches: type === 'touchend' ? [] : [mkTouch(x)], + }); + + // Begin the gesture — #onTouchStart snapshots the paint context once. + let x = 400; + paginator.dispatchEvent(mkEvent('touchstart', x)); + + // Install the spy AFTER the touchstart snapshot so we measure only the + // per-drag-frame repaints, not the one-time snapshot read. + let htmlStyleReads = 0; + const origGetComputedStyle = win.getComputedStyle.bind(win); + win.getComputedStyle = ((element: Element, pseudo?: string | null) => { + if (element === htmlEl) htmlStyleReads++; + return origGetComputedStyle(element, pseudo ?? undefined); + }) as typeof win.getComputedStyle; + + try { + // Move the finger leftward in steps, one per frame, so each scrollBy emits + // its own (non-coalesced) container scroll event → one drag repaint each. + for (let i = 0; i < 6; i++) { + x -= 30; + paginator.dispatchEvent(mkEvent('touchmove', x)); + await nextFrame(); + } + paginator.dispatchEvent(mkEvent('touchend', x)); + await nextFrame(); + } finally { + win.getComputedStyle = origGetComputedStyle; + } + + // Each drag frame repainted the per-view backgrounds; pre-fix that rebuilt the + // whole context (a getComputedStyle) every frame. The drag snapshot + // must make those frames read-free. + expect(htmlStyleReads).toBeLessThanOrEqual(1); + }); +}); diff --git a/packages/foliate-js b/packages/foliate-js index 982f168c..15fc999f 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 982f168c668dedcf38b2304bec788337712d60aa +Subproject commit 15fc999f854a41ed6111b8d57c7f25fb97693419