Skip to content

Resolve fallback chapter info lazily per displayed spine item - #288

Open
mbret wants to merge 1 commit into
masterfrom
chore/perf-2026-08-03-lazy-chapter-info
Open

Resolve fallback chapter info lazily per displayed spine item#288
mbret wants to merge 1 commit into
masterfrom
chore/perf-2026-08-03-lazy-chapter-info

Conversation

@mbret

@mbret mbret commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Target

Subsystem: pagination → chapter-info tracking (packages/core/src/enhancers/pagination).

When a book is opened, trackPaginationInfo builds chaptersData once. Part of that was buildStaticChaptersInfo(manifest, tocIndex), which eagerly resolved the href-based fallback chapter info for every spine item and stored it in a { [id]: ChapterInfo } record.

That record is only ever read for the handful of spine items actually on screen (mapChapterInfo reads chaptersInfo[beginItem.id] / [endItem.id], and only as a fallback when the DOM-based resolver returns nothing). On a large book (hundreds → 1000+ spine items) this is a chunk of main-thread work done at open whose result is mostly thrown away — it delays the first pagination/chapter readout with no user-visible payoff.

Changes

Mechanism (one sentence): resolve the fallback chapter info on demand and memoize per spine-item id instead of eagerly for the whole spine, so the work becomes proportional to the items the reader visits rather than to book size.

  • buildStaticChaptersInfocreateStaticChaptersResolver: a resolver with a get(spineItemId) that resolves lazily and caches the result (including cached undefined).
    • Scale multiplier: spine items. Eager cost was O(spineItems × tocEntries) at open; lazy cost is O(visitedItems × tocEntries) spread across navigation. Unvisited items (the majority on a large book) cost nothing.
  • Inside findChapterChainByHref, replace the per-call manifest.spineItems.findIndex(...) with a lookup into a prebuilt href → firstIndex map (getSpineItemIndexByHref, already used by buildTocIndex).
    • Scale multiplier: removes an O(spineItems) scan from each resolution (an accidental O(spineItems²) pass across the old eager build).

Behavior is unchanged: same ChapterInfo for the same input, first-occurrence-wins for duplicate hrefs (matches findIndex) and last-write-wins for duplicate spine-item ids (matches the old record[id] = … assignment).

createStaticChaptersResolver / StaticChaptersResolver are internal to the pagination enhancer (not part of the package's public entry point and not referenced in gitbook/), so no public surface or docs change is required — checked.

Impact (measured)

Micro-benchmark of the resolver logic at realistic scale, simulating "open a book, then read N distinct chapters (the rest never displayed), with a few page turns each". Old = eager-resolve all items at open; new = lazy + memoized. Output verified identical (including duplicate-href and duplicate-id cases).

spine items toc/item chapters read old eager new lazy speedup
300 1 10 15.1 ms 0.51 ms ~30×
500 1 10 40.4 ms 0.84 ms ~48×
1000 1 20 160 ms 3.3 ms ~49×
500 3 10 120 ms 2.6 ms ~46×
1000 3 20 466 ms 9.2 ms ~51×

The saved work is the fallback resolution for every spine item the reader never opens — which on a large book is nearly all of them. Small books (few spine items) are unaffected either way.

Gates

Run with the deps installed for this repo. Note: the pinned Node (v25, per .nvmrc) could not be provisioned in this environment, and the @prose-reader/cfi package's native rolldown/vite build fails here as a result — a pre-existing, environment-only limitation confirmed on a clean checkout, unrelated to this diff. Given that:

  • Unit tests (packages/core): full suite 186/186 pass (workspace deps resolved from source to work around the unbuilt @prose-reader/cfi), including chapters.test.ts and trackPaginationInfo.test.ts.
  • Typecheck (tsc): the only errors are the 3 pre-existing Cannot find module '@prose-reader/cfi' ones (unbuilt dep); zero new errors in the changed files.
  • Lint/format (biome check): clean on all changed files (also enforced by the pre-commit hook).
  • Full build / CI on Node 25 will exercise the remaining gates.

Backlog (found, not taken)

  • findChapterChainByHref still scans the whole tocIndex per resolution (O(tocEntries), dominated by isPossibleTocItemCandidateForHref's endsWith checks). The existing buildTocCandidatesBySpineHref pre-groups candidates by spine href, but it uses a different match predicate (hrefMatchesWithoutAnchor), so reusing it here would change chapter-resolution semantics — left alone deliberately.
  • SpineItemsLoader (spine/loader) does indexesToLoad.includes(index) inside a loop over all spine items; a Set makes it O(1) per item. Only meaningful when numberOfAdjacentSpineItemToPreLoad is Infinity (load-whole-book) — different subsystem, deferred.
  • getSpineItemFromIframe (spine/locator) does an O(spineItems) .find on every passed-through pointer event; a frame→item map would make it O(1). Hot path, but needs load/unload map maintenance — deferred.

🤖 Generated with Claude Code

https://claude.ai/code/session_014NrSMiUDLXhYPYar2dg3KP


Generated by Claude Code

buildStaticChaptersInfo eagerly resolved href-based fallback chapter info
for every spine item at book open, but that map is only ever read for the
few items actually displayed (mapChapterInfo). On large books this was an
O(spineItems × tocEntries) main-thread pass whose result was mostly
discarded.

Replace it with createStaticChaptersResolver, which resolves on demand and
memoizes per spine-item id, so the cost is proportional to the items the
reader visits. Each resolution also drops an O(spineItems) findIndex by
reusing a prebuilt href → index map. Output is identical, including the
duplicate-href / duplicate-id (last-write-wins) edge cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NrSMiUDLXhYPYar2dg3KP
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
prose-reader-demo Ready Ready Preview Aug 3, 2026 7:41am
prose-reader-front Ready Ready Preview Aug 3, 2026 7:41am

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants