From b1eea16544141d5ec535379481c01576af5d01bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:32:16 +0000 Subject: [PATCH] Resolve fallback chapter info lazily per displayed spine item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014NrSMiUDLXhYPYar2dg3KP --- .../enhancers/pagination/chapters/index.ts | 6 +- .../enhancers/pagination/chapters/shared.ts | 9 +- .../enhancers/pagination/chapters/static.ts | 87 ++++++++++++------- .../pagination/trackPaginationInfo.ts | 13 +-- 4 files changed, 79 insertions(+), 36 deletions(-) diff --git a/packages/core/src/enhancers/pagination/chapters/index.ts b/packages/core/src/enhancers/pagination/chapters/index.ts index 035a6808a..f1bebea84 100644 --- a/packages/core/src/enhancers/pagination/chapters/index.ts +++ b/packages/core/src/enhancers/pagination/chapters/index.ts @@ -1,4 +1,8 @@ export { resolveChapterInfoFromVisibleNode } from "./node" export { buildTocCandidatesBySpineHref, buildTocIndex } from "./shared" -export { buildChaptersInfo, buildStaticChaptersInfo } from "./static" +export { + buildChaptersInfo, + createStaticChaptersResolver, + type StaticChaptersResolver, +} from "./static" export type { ChapterInfo, TocCandidatesBySpineHref, TocIndex } from "./types" diff --git a/packages/core/src/enhancers/pagination/chapters/shared.ts b/packages/core/src/enhancers/pagination/chapters/shared.ts index 2899bc0f4..c2f41f65a 100644 --- a/packages/core/src/enhancers/pagination/chapters/shared.ts +++ b/packages/core/src/enhancers/pagination/chapters/shared.ts @@ -52,7 +52,14 @@ const flattenToc = ( }) } -const getSpineItemIndexByHref = (manifest: Manifest) => { +/** + * Map every spine href to the index of its first occurrence. + * + * Building this once lets callers resolve a spine item index by href in O(1) + * instead of scanning `manifest.spineItems` per lookup. First-occurrence wins, + * which matches `Array.prototype.findIndex` semantics for duplicate hrefs. + */ +export const getSpineItemIndexByHref = (manifest: Manifest) => { const indexByHref = new Map() manifest.spineItems.forEach((item, index) => { diff --git a/packages/core/src/enhancers/pagination/chapters/static.ts b/packages/core/src/enhancers/pagination/chapters/static.ts index 13cecad09..4c32a7cbf 100644 --- a/packages/core/src/enhancers/pagination/chapters/static.ts +++ b/packages/core/src/enhancers/pagination/chapters/static.ts @@ -2,6 +2,7 @@ import type { Manifest } from "@prose-reader/shared" import { buildChapterInfoFromChain, buildTocIndex, + getSpineItemIndexByHref, isPossibleTocItemCandidateForHref, stripAnchor, } from "./shared" @@ -26,17 +27,15 @@ const shouldSkipAnchorSubChapter = ({ const findChapterChainByHref = ({ href, tocIndex, - manifest, + spineItemIndexByHref, }: { href: string tocIndex: FlatTocEntry[] - manifest: Manifest + spineItemIndexByHref: Map }): TocPathEntry[] | undefined => { const hrefWithoutAnchor = stripAnchor(href) const hrefHasAnchor = href.includes(`#`) - const spineItemIndex = manifest.spineItems.findIndex( - (item) => item.href === hrefWithoutAnchor, - ) + const spineItemIndex = spineItemIndexByHref.get(hrefWithoutAnchor) ?? -1 let bestChain: TocPathEntry[] | undefined @@ -66,37 +65,67 @@ export const buildChaptersInfo = ( manifest: Manifest, ): ChapterInfo | undefined => { const tocIndex = buildTocIndex(tocItem, manifest) - const chapterChain = findChapterChainByHref({ href, tocIndex, manifest }) + const spineItemIndexByHref = getSpineItemIndexByHref(manifest) + const chapterChain = findChapterChainByHref({ + href, + tocIndex, + spineItemIndexByHref, + }) return chapterChain ? buildChapterInfoFromChain(chapterChain) : undefined } -const buildChapterInfoFromSpineItem = ( - manifest: Manifest, - tocIndex: TocIndex, - item: Manifest[`spineItems`][number], -) => { - const { href } = item - - const chapterChain = findChapterChainByHref({ href, tocIndex, manifest }) - - return chapterChain ? buildChapterInfoFromChain(chapterChain) : undefined +export type StaticChaptersResolver = { + /** + * Fallback chapter info for a spine item, resolved from its own href against + * the TOC. Returns `undefined` for spine items not present in the TOC. + */ + get: (spineItemId: string) => ChapterInfo | undefined } -export const buildStaticChaptersInfo = ( +/** + * Lazily resolve the static (href-based) fallback chapter info per spine item. + * + * This fallback is only read for the few spine items actually displayed (see + * `mapChapterInfo`), yet the previous implementation eagerly resolved it for + * *every* spine item at book open — an O(spineItems × tocEntries) pass whose + * result was mostly thrown away on large books. Resolving on demand and caching + * per id makes the cost proportional to the items the reader visits, and each + * resolution avoids an O(spineItems) `findIndex` by reusing a prebuilt + * href → index map. + */ +export const createStaticChaptersResolver = ( manifest: Manifest, tocIndex: TocIndex, -): { [key: string]: ChapterInfo | undefined } => { - if (!manifest) return {} - - const chaptersInfo = manifest.spineItems.reduce( - (acc, item) => { - acc[item.id] = buildChapterInfoFromSpineItem(manifest, tocIndex, item) - - return acc +): StaticChaptersResolver => { + const spineItemIndexByHref = getSpineItemIndexByHref(manifest) + + // Last write wins, matching the previous `record[item.id] = …` assignment + // semantics when several spine items share an id. + const hrefBySpineItemId = new Map() + manifest.spineItems.forEach((item) => { + hrefBySpineItemId.set(item.id, item.href) + }) + + const cache = new Map() + + return { + get: (spineItemId) => { + const cached = cache.get(spineItemId) + if (cached !== undefined || cache.has(spineItemId)) return cached + + const href = hrefBySpineItemId.get(spineItemId) + const chapterChain = + href !== undefined + ? findChapterChainByHref({ href, tocIndex, spineItemIndexByHref }) + : undefined + const chapterInfo = chapterChain + ? buildChapterInfoFromChain(chapterChain) + : undefined + + cache.set(spineItemId, chapterInfo) + + return chapterInfo }, - {} as { [key: string]: ChapterInfo | undefined }, - ) - - return chaptersInfo + } } diff --git a/packages/core/src/enhancers/pagination/trackPaginationInfo.ts b/packages/core/src/enhancers/pagination/trackPaginationInfo.ts index e24532393..21024d748 100644 --- a/packages/core/src/enhancers/pagination/trackPaginationInfo.ts +++ b/packages/core/src/enhancers/pagination/trackPaginationInfo.ts @@ -15,17 +15,18 @@ import { Pages, type PagesState } from "../../spine/Pages" import type { SpineItem } from "../../spineItem/SpineItem" import type { LayoutEnhancerOutput } from "../layout/layoutEnhancer" import { - buildStaticChaptersInfo, buildTocCandidatesBySpineHref, buildTocIndex, + createStaticChaptersResolver, resolveChapterInfoFromVisibleNode, + type StaticChaptersResolver, type TocCandidatesBySpineHref, } from "./chapters" import { getPercentageEstimate } from "./progression" type ChaptersData = { tocCandidatesBySpineHref: TocCandidatesBySpineHref - chaptersInfo: ReturnType + chaptersInfo: StaticChaptersResolver } type ChapterPaginationInfo = Pick< @@ -97,12 +98,14 @@ const mapChapterInfo = ({ return { beginChapterInfo: beginChapterInfoFromVisibleNode ?? - (beginItem ? chaptersData.chaptersInfo[beginItem.item.id] : undefined), + (beginItem + ? chaptersData.chaptersInfo.get(beginItem.item.id) + : undefined), beginSpineItemReadingDirection: beginItem?.readingDirection, beginAbsolutePageIndex: beginPageEntry?.absolutePageIndex, endChapterInfo: endChapterInfoFromVisibleNode ?? - (endItem ? chaptersData.chaptersInfo[endItem.item.id] : undefined), + (endItem ? chaptersData.chaptersInfo.get(endItem.item.id) : undefined), endSpineItemReadingDirection: endItem?.readingDirection, endAbsolutePageIndex: endPageEntry?.absolutePageIndex, } @@ -186,7 +189,7 @@ const observeChaptersData = (reader: Reader & LayoutEnhancerOutput) => return { tocCandidatesBySpineHref, - chaptersInfo: buildStaticChaptersInfo(manifest, tocIndex), + chaptersInfo: createStaticChaptersResolver(manifest, tocIndex), } }), )