Skip to content

Add a WebView record cache and page client - #529

Merged
alex-rawlings-yyc merged 6 commits into
451-segment-page-phrase-spansfrom
452-webview-record-cache
Oct 29, 2026
Merged

alex-rawlings-yyc merged 6 commits into
451-segment-page-phrase-spansfrom
452-webview-record-cache

Conversation

@alex-rawlings-yyc

@alex-rawlings-yyc alex-rawlings-yyc commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Part of #426; closes #452. Stacked on #528.

Summary

This PR adds the WebView's record cache and page client. Both are built and tested, but nothing calls them yet.

src/cache/recordCache.ts   # RecordCache: one book's held pages and their records
src/cache/pageClient.ts    # PageClient: fills a RecordCache from a PageSource

RecordCache.absorb(page, sentAt)       # takes in a fetched page
RecordCache.apply(reply) -> Refetch    # 'none' | 'pages' | 'placement' | 'outline'
RecordCache.pageAt(start, end)         # serves a held range as the host would page it
PageClient.open() / load(start, end, caller) / release(caller) / retryFailed()
PageClient.send(command) / hear(change) / subscribe(listener) / settled()
PageClient.outline / holds(index) / pageAt(start, end) / failedPages / outlineFailed
PageClient.beginFetch() / absorb(page, fetch)   # the seam perf:pages stages races through

CommandReply.resegmentedBooks        # the books whose boundaries the change moved
CommandReply.staleTranslationBooks   # the books where the change put or removed a stale free translation
AnalysisChange.forms            # the forms whose pool buckets the change may have changed
  • RecordCache holds what a view has fetched. That covers the segments, the links and payloads on them, pool buckets, phrase spans and placed stale free translations. pageAt serves a held range in the host's SegmentPage shape, so a reply applied here can be compared with a refetch.
  • A refetch never overwrites a newer reply. revision counts the replies applied. A page request notes the revision when it is sent. absorb then skips any record or pool bucket that a later reply wrote or removed.
  • apply says what it can't apply itself. It returns 'outline' when the reply's resegmentedBooks names the book. It returns 'placement' when the reply's staleTranslationBooks names the book, because the cache can't place stale translations or count orphaned cards. That refetches the held pages and the outline's counts, and the held pages are served meanwhile. It returns 'pages' when a reply puts a phrase over a token that the cache doesn't hold. Otherwise it returns 'none'.
  • PageClient keeps the behavior of Serve book outlines and segment pages from the host (#436) #464's useSegmentPages and Render the continuous view from segment pages #467's per-view ranges. Pages have a fixed size, and a failed load gets bounded retries. The client keeps the latest range of each view ('list' | 'strip'), reloads those ranges after a new outline, and drops a range on release. Each page has one fetch state (in flight, backing off or failed), changed through one transition, so only one retry chain runs. A page that fails every retry, or an outline read that fails, waits for retryFailed (Tell the reader when a segment page fails to load #471) or a later outline read. Outline reads run one at a time.
  • Replies and reported changes. send sends a command under a change id it mints, applies the reply and fetches whatever the reply leaves out of date. hear takes the session's onAnalysisChanged change and refetches the held pages in its ranges. It also refetches the held pages that read one of its analyses or the pool bucket of one of its forms. A change naming the book whole refetches the outline, and one naming the book and getStaleSummary refetches the placement. It skips the change reported for a command it sent, whether that change arrives before the reply or after it, unless the command throws. A page that a change reaches while its fetch is in flight, a first load included, is fetched again once it lands.
  • Consumers read the client, not the cache. outline, holds and pageAt are read-only, and failedPages is a snapshot that stays the same object until the client's version changes.
  • A page cut by an earlier outline is dropped and fetched again if a view still asks for its range. A page that comes back cut from other text makes the client ask for the outline again. A new outline also drops the held phrase spans, so a phrase never spans a word that the text no longer holds.
  • Replies and events name a little more. CommandReply.resegmentedBooks names the books whose boundaries a change moved, so the cache needn't compare scope records. CommandReply.staleTranslationBooks names the books that the change's event flags with staleTranslations. AnalysisChange.forms names the forms whose pool buckets a change may have changed, read as the reply's pool buckets are. PAGE_SIZE, PAGE_RETRY_DELAYS_MS, PageCaller, pagesCovering and waitMs live in pageClient.ts, and useSegmentPages imports them.

Evidence

The done-when, and where each part is checked:

The #407 tiers are checked only by npm run perf:pages, run by hand. CI runs only the unit tests, which use a small fixture.

  • Applying a reply gives the same pages and pool buckets as a refetch. recordCache.test.ts and pageClient.test.ts compare pageAt against a fresh getSegments after an edit. npm run perf:pages checks this for every change over a Data layer: capture a performance baseline before the database migration #407 tier. Each run applies random commands, undos and redos through a PageClient, then compares every page and the outline with what the session serves.
  • After a boundary command, the outline matches a refetch. perf:pages includes splitSegment among its commands and compares the cached outline after every change. Unit tests cover a reply that moves boundaries, and a first reply after open that names the boundaries unchanged.
  • Discards. perf:pages includes discardBook, restoring the book as the draft held it at open. RecordCache tests apply a discardBook reply and a discardAll reply and compare pageAt with a refetch, and check that a discard restoring boundaries asks for the outline.
  • A refetch that lands after a newer reply leaves the reply's records in place. A dedicated RecordCache test covers this. In perf:pages, about 30% of changes also race a refetch sent before the change and absorbed after it. Changes whose reply carries a scope record are left out of the race, because a boundary change refetches the outline anyway. A PageClient test covers a page that lands between a boundary reply and the outline it asks for.
  • perf:pages feeds the session's reported changes to hear as they are emitted. It reports 0 differences on every tier:
    • small: 177 changes (37 refetch races), at b273692.
    • medium: 37 changes (9 refetch races), at b273692.
    • large: 10 changes (2 refetch races), at 5985851.
    • large-loadable: 10 changes (2 refetch races), at b273692.
  • 24 RecordCache and 42 PageClient unit tests run over a session in session-source.ts, a real AnalysisSessions over a DraftRepository, which hands each change it reports to the client in the order it emits them. They cover phrases over unheld tokens, stale free translations, discards, retries, changes during a first load and during backoff, both orders of reply and event, failed outline reads, released ranges, replaced outlines, in-flight refetches and listeners.
  • The full suite passes at 100% coverage; lint and tsc are clean.

Decisions with a strong alternative

  • Freshness is tracked per record, by reply revision. A page request notes the revision when sent, and absorb keeps whatever a later reply wrote. The alternative was to drop any page sent before the latest reply. That would refetch pages a reply never touched.
  • The cache refetches rather than derive what it can't compute locally. It asks for the outline when a boundary changes, for its pages and the outline's counts when a stale free translation changes, and for its pages when a phrase spans a token it doesn't hold. These are rarer than glosses and approvals, which apply locally.
  • hear refetches only the held pages that a change reaches, and events name the forms they touch. Refetching every held page for any change naming analyses would refetch them all for each re-anchor pass during the session-open read (Re-anchor every linked book in the host #507). Analysis ids alone miss a new payload elsewhere whose form a held page shows, so the event names forms too.
  • A reply names the books where a stale free translation changed, read from the change's event. The alternative was for the cache to guess from the records it holds. A guess refetches the placement for any removed translation it doesn't hold, and misses a stale translation kept on a page it doesn't hold.
  • PageClient sends commands itself rather than callers registering a pending id, so the client knows each id before the session reports its change. 4a builds on send.
  • The cache holds one book. Cross-book queries stay with whoever opens the client.

Merge Danger

Door: two-way

Nothing imports src/cache/ outside its tests and perf/pages/cli.ts, so no runtime behavior changes.

Blast Radius: two new modules, their tests, and perf/pages/cli.ts. perf:pages now also catches ApprovalRuleError from a random edit and counts the edit as applying nothing. Phase 4 wires the client to the WebView. CommandReply gains resegmentedBooks and staleTranslationBooks, and the session form of onAnalysisChanged gains forms. RecordTable moves to src/utils/record-table.ts, and its put and remove become public, since the cache holds its records in RecordTables. The discards from #524 reach the cache as ordinary replies: they refetch the outline when they restore boundaries, and their puts and deletes apply otherwise.

🤖 Generated with Claude Code

Stack created with GitHub Stacks CLI • Give Feedback 💬


This change is Reviewable

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8b63fbaf-3f32-4de4-97b4-b435504d7dcc
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@imnasnainaec imnasnainaec left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Discards. The comment on #452 asked for the done-when to cover them, and Blast Radius says neither the tests nor perf:pages run discardBook or discardAll against the cache. A discard can replace whole books and always puts the scope record. So it takes the first-scope-reply path (see the comment on recordCache.ts), and its reply carries its whole ChangeSet. Two options:

  1. Add RecordCache tests that apply a discardBook reply and a discardAll reply and compare pageAt with a refetch. Add discardBook to the perf:pages command list.
  2. Leave discards to 4a, and comment on #453: its end-to-end tests must cover discards with the flag on, and 4a decides whether the cache applies or refetches a discardAll reply on large-loadable.

⛏️ The done-when says "in tests over the #407 datasets". The #407 coverage comes only from running perf:pages by hand; CI runs only the unit tests, which use a small fixture. Could Evidence say so? The same applies to #527 and #528.


Drafted by Claude Opus 5.5; barely reviewed by me.

Comment thread src/cache/pageClient.ts Resolved
Comment thread src/cache/recordCache.ts Resolved
Comment thread src/cache/recordCache.ts Resolved
Comment thread src/cache/recordCache.ts Resolved
Comment thread src/cache/pageClient.ts Resolved
Comment thread perf/pages/cli.ts Resolved
@alex-rawlings-yyc

Copy link
Copy Markdown
Contributor Author

Discards: took option 1. RecordCache tests apply a discardBook reply and a discardAll reply and compare pageAt with a refetch, and a third checks that a discard restoring boundaries asks for the outline. perf:pages now runs discardBook, restoring the book as the draft held it at open. One correction: a discard puts the scope record only when it restores different boundaries, not always.

Evidence: added a line to #527, #528 and #529 saying the #407 tiers are checked only by perf:pages, run by hand, and that CI runs only the unit tests over a small fixture.

@imnasnainaec imnasnainaec left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review for reinvented or duplicated code (things the repo or paranext-core already has, or repeated within the PR). Drafted by Claude Opus 5.5 (claude-opus-5-5); inline comments not reviewed by me.

Comment thread src/cache/recordCache.ts Resolved
Comment thread src/cache/recordCache.ts Resolved
Comment thread src/cache/recordCache.ts Resolved
Comment thread src/cache/recordCache.ts Resolved
Comment thread src/cache/pageClient.ts Resolved

@imnasnainaec imnasnainaec left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also filed for later: #550 (page assembly copied from sessionPages/page-indexes) and #551 (cache state only grows).

Review drafted by Claude Opus 5.5.

Comment thread src/cache/pageClient.ts Resolved
Comment thread src/cache/pageClient.ts Resolved
Comment thread src/__tests__/cache/pageClient.test.ts Resolved
Comment thread src/cache/pageClient.ts Resolved
Comment thread src/cache/pageClient.ts Resolved
Comment thread src/cache/pageClient.ts Resolved
Comment thread perf/pages/cli.ts Resolved
Comment thread AGENTS.md Resolved
Comment thread src/services/analysisSessions.ts Resolved
@alex-rawlings-yyc
alex-rawlings-yyc enabled stacked auto-merge (squash) October 29, 2026 19:50
@alex-rawlings-yyc
alex-rawlings-yyc disabled stacked auto-merge October 29, 2026 19:50

@imnasnainaec imnasnainaec left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@imnasnainaec partially reviewed 25 files and all commit messages, and resolved 20 discussions.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on alex-rawlings-yyc).

@alex-rawlings-yyc
alex-rawlings-yyc merged commit 52eeeb9 into main Oct 29, 2026
23 checks passed
@alex-rawlings-yyc
alex-rawlings-yyc deleted the 452-webview-record-cache branch October 29, 2026 20:27
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.

[3m] Build the WebView record cache and page client

2 participants