perf(reader): coalesce relocate events and memoize BookCell to stop per-swipe storm - #4562
Conversation
…er-swipe storm
FoliateViewer
-------------
foliate fires `relocate` multiple times during a swipe burst (snap
steps + intermediate stabilize). Each one ended up in setProgress,
which writes to readerProgressStore + bookDataStore. Coalesce them to
a single commit per animation frame so only the final viewport state
is persisted.
Earlier this used requestIdleCallback, but profiling on Android showed
"Fire Idle Callback" ballooning to 2.0+ s of total time per ~28 s
session: rIC backed up under sustained pressure and dumped the whole
queue into the post-swipe pause, producing exactly the "feels sluggish
right after I let go" jank we were trying to fix. rAF runs once per
frame, gets scheduled by the browser's normal vsync loop, and doesn't
accumulate when the page is busy.
BooksGrid -> BookCell
---------------------
Previously BooksGrid subscribed to the entire progresses map and
rendered every book inline. The map changes on every page turn, so the
whole bookKeys.map(...) body re-ran for every swipe. On top of that
inset-related objects (gridInsets, contentInsets) were rebuilt every
render and threaded as fresh references into 7+ children, so even
unchanged children couldn't bail out. That accounted for ~27% of
main-thread time in the Bottom-Up profile ("Animation Frame Fired"
2.6s / 27%).
Extract BookCell as its own React.memo'd component:
- Each cell subscribes only to its own book's progress via
useBookProgress(bookKey). A page turn re-renders one BookCell, not
the grid.
- viewInsets / contentInsets are memoized off their numeric inputs so
children get stable prop references across renders.
- BookCell uses per-field selectors internally for the same reason
spelled out in store/readerProgressStore.ts header.
- Dropdown handlers are wrapped in useCallback so HeaderBar's props
object stays stable.
77ddb65 to
74f0226
Compare
…on toggles apply live BookCell subscribed reactively only to useBookProgress and read viewState/viewSettings imperatively. Settings that save with applyStyles=false (Show Header/Footer, Double Border, Border Color) and the bookmark ribbon toggle write no progress, so the cell didn't re-render and the chrome it gates (SectionInfo, ProgressBar, DoubleBorder, Ribbon) only updated on the next page turn. Subscribe to the per-book viewStates[key] slice. This is safe now that progress lives in its own store — viewStates[key] only bumps on low-frequency events (settings toggles, ribbon, init, sync), never on the per-swipe relocate path — so it does not reintroduce the commit storm the progress-store split removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Reviewed and pushed a follow-up fix ( Issue: the
Since Fix: The
Minor, left as-is: |
…4900) Adjusting the top, bottom, left, or right page margin had no visible effect until an unrelated setting (e.g. Show Header) was toggled. The BooksGrid perf refactor (#4562) memoized the derived view/content insets on the ViewSettings object identity. saveViewSettings mutates ViewSettings in place (same reference), so the memo never recomputed on a margin edit and the new margin never reached the paginator. Left and right margins were always stale; top and bottom only refreshed when the header/footer visibility (an effect dependency) changed, which is why toggling the header appeared to apply a pending change. Extract the inset derivation into useContentInsets and memoize by the resolved numeric values instead of the object reference: identical numbers across a page turn keep a stable reference (no re-render storm), while a changed margin yields a new one that propagates to the renderer. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Two complementary changes that stop a swipe burst from re-rendering the full reader tree on every intermediate
relocate.FoliateViewer — coalesce relocate
foliate fires
relocatemultiple times during a swipe burst (snap steps plus the final stabilize). Each one ended up insetProgress, which writes toreaderProgressStoreplusbookDataStore. This change coalesces them into a single commit per animation frame viarequestAnimationFrame, so only the final viewport state is persisted.An earlier iteration used
requestIdleCallback, but profiling on Android showed "Fire Idle Callback" ballooning to ~2 s of total time per ~28 s session: rIC backed up under sustained pressure and dumped the whole queue into the post-swipe pause, producing exactly the "feels sluggish right after I let go" jank the change was trying to fix.rAFruns once per frame, gets scheduled by the browser's normal vsync loop, and doesn't accumulate when the page is busy.BooksGrid → BookCell
Previously
BooksGridsubscribed to the entireprogressesmap and rendered all per-book chrome inline, so every page turn re-rendered every cell in the grid (cheap but multiplicative when parallel views are open).Extract a memoized
BookCellcomponent that:useBookProgress(bookKey)— a page turn re-renders one cell;React.memodoesn't get defeated;gridInsets/contentInsetsfromBooksGridso memoization on prop identity is meaningful.Why this matters
Pairs with the progress-store split (#4557). #4557 isolates the high-frequency write source from the wider reader store; this PR makes sure the now-tight reactive surface only fires on full-frame transitions and that only one cell at a time gets the update.
Depends on #4557
This PR's first commit duplicates #4557 (split progress store) so the second commit can compile in isolation (it uses
useBookProgress). Once #4557 lands, GitHub will collapse the diff to only the coalesce / BookCell changes. Please merge #4557 first.Testing
All existing tests pass (
pnpm test— 5288 passed).