ADR-019 — Reader position: shared rules, not a shared state machine
Status: Accepted · Date: 2026-10-05 · Extends ADR-015, ADR-013 · Related ADR-012, ADR-014 · Background: bug hunt R1/R2, R3 #723–#725, the reader-engine evaluation (deleted; see git history).
Context
About two thirds of the 47 R1–R3 reader findings were restore/save/sync logic, not rendering. Each rule
was written per platform and lives in refs and effects: mobile useReaderPersistence.ts has a readiness
reducer, a write-gate reducer, a restore-id ref, leavingRef, sessionJumpRef, moveBaselineRef,
rebuildTargetRef. Web has fewer guards than mobile. Web lands a restore in the same rAF as its
scrollTo, so it has no “issued but not landed” window. MAX_CLIENT_SKEW_MS was only in mobile
(progressRestore.ts) until #724 added a copy in shared progressPrecedence.ts.
Live bugs the ADR-019 review found:
- Web: a PDF saves page 1 before the resume jump (no provenance gate). The progress GETs have no
timeout: if the GET hangs, nothing is restored or saved.
wordsRead≈ 0. A stale tab overwrites another device’s position. One progress GET is sent twice. - Mobile: a rebuild during a restore restores to the top and saves 0. Settings load async, so this hits every open with non-default typography. A known PDF chapter start page drops the device page. The foreground newer-check is skipped after the OS kills the renderer in the background.
Decision
Lift and adopt. Pure helpers live in packages/shared/src/reader/, one home each. Hosts keep the
rest. No single cross-platform reducer.
| Shared module | Contents |
|---|---|
newerPosition.ts (from mobile progressRestore.ts) |
serverProvablyNewer, decideNewerPosition, readerMovedSince, trustedLocalStamp |
progressPrecedence.ts |
localProgressWins; the only MAX_CLIENT_SKEW_MS |
pdfPersistGate.ts |
as is; web adopts it |
restoreGate.ts (later, only if needed) |
mobile readinessReduce + restoreGateReduce, merged |
Hosts keep save timing (debounce, flush), I/O, holds (?highlight=), toasts, snapshots, capture and
navigation. Web adopts the shared helpers. Mobile keeps its hooks and only re-points imports.
Rules
Each rule names its bug and the test that pins it. New scenarios from the review go into these existing files as rows, not into a new test framework.
| # | Rule | Bug | Pinned by |
|---|---|---|---|
| 1 | Per-doc key from the route, never from the resolved id. The book id arriving for the same doc does not reset “doc loaded”. | Mobile C1 | restoreReadiness.test.ts |
| 2 | Restore only when doc ready AND target loaded, whichever is last, once per doc. The doc must be the requested chapter. | Mobile C1, web C2 | restoreReadiness.test.ts, useReaderScrollSync.test.ts (isChapterReady) |
| 3 | Land on the ack, or positionally only from the first open (non-zero scroll, PDF ±1 page), or on a 4 s deadline. A stale restore id lands nothing. | Back in 1 s → 0.66 %; jump pages saved; page 1 saved | readerWriteGate.test.ts, pdfPersistGate.test.ts |
| 4 | A pending save belongs to the doc it was captured in. On a chapter change, flush it for the old doc. On an owner/layout switch (PDF ↔ reflow), drop it. | Web C1; mobile H1, PDF 14 % → 4 % | useReaderScrollSync.test.ts, readerWriteMode.test.ts |
| 5 | Server data enters only as a newer-check after the open: client stamps only, local clamped to now + 5 min; adopt before the restore, move if the reader has not moved, otherwise ask. Never yank a reader who moved. | #695, #710, L5, R3 L1, mobile C2/H3 | progressRestore.test.ts, readerSources.test.ts (mobile), progressPrecedence.test.ts, positionHandoff.test.ts |
| 6 | Every wait has a deadline. Restore/jump 4 s, ?highlight= hold 5 s, progress GET 3 s. The reading path never waits on a token or the network (ADR-014: no auth input). |
Captive portal; web hung GET | readerSessionGate.test.ts; web GET timeout test (R4) |
| 7 | A programmatic move is not reading (restore, newer move, rebuild, reflow, jump). | M8; web wordsRead ≈ 0 |
sessionMath.test.ts |
| 8 | A rebuild or reflow during a restore keeps the pending target. It does not snapshot the half-restored view. | Mobile: rebuild mid-restore saves 0 | rebuildRestore.test.ts (row added in R4) |
Plan
- R4 web PR: fix the live web bugs. Move the helpers to shared, with one
MAX_CLIENT_SKEW_MS. Web adoptspdfPersistGateand the newer-check. Each fix is tests-first. - R4 mobile PR: fix the live mobile bugs (rule 8, the PDF start page, the foreground check after a renderer kill). Re-point imports to shared. JS-only, OTA.
- Later, only if a new bug lands in the gap between refs: merge
readinessReduce+restoreGateReduceinto sharedrestoreGate.ts(mobile first).
Alternatives rejected
-
One full cross-platform state machine (the first draft of this ADR). The review found blockers. A “behaviour-preserving” rewrite would have brought R1 Criticals back:
offwhen there is no book key, then a doc reset, brings back C1.canPersistread at fire time breaks web C1 or mobile H1.- Positional landing from any restore brings back the jump-saved bug.
- The transitions were not total: a rebuild or reflow can arrive while opening or restoring.
Moving mobile from React state to a ref changes which report gets saved, when the baseline is set, and how stale acks behave. Web gains little, because it lands in the same rAF. And a big reducer would fix today’s renderer shapes against a future engine’s
Locator/goTo(). -
XState. A new dependency in two bundles, timers hidden from tests, and the same lock-in.
-
Reader engine first. By the evaluation’s own count it removes about a third of the findings; these rules are needed whatever engine we pick.
-
Status quo. Web keeps missing guards that mobile has; the same bug is fixed twice, weeks apart.
Consequences
- Smaller change: helpers move and web catches up. No rewrite of code that took 47 fixes.
- The state stays spread over refs on mobile. That is accepted until a bug proves the gap (plan step 3).
- Each rule has one home and one pinning test; a new rule adds a row, not a framework.
Consilium (2026-10-05)
Four reviewers looked at the first draft (a full state machine):
- Simplicity critic: reject. The helpers already exist, and most of the value is web catching up.
- Adversarial QA: blockers. C1 comes back; the fire-time gate fails; positional landing is too wide; transitions are not total.
- Mobile: blocker. M1 is not behaviour-preserving (ref vs state, baseline, stale acks).
- Web: low gain from W1 (same-rAF landing). The real value is the live web bugs.
The lead (owner-delegated) chose lift and adopt.
Open questions (owner)
- A TOC jump to another chapter, then a newer same-chapter position from another device arrives before you scroll: mobile moves you silently today. Keep that, or ask first?
- Should web get the “read further on another device” toast later?