Skip to content

fix(reader): 🐛 keep the reading position across refocus and rotation (#95) - #96

Merged
vitofico merged 2 commits into
mainfrom
fix/reader-position-lost-on-refocus
Aug 18, 2026
Merged

fix(reader): 🐛 keep the reading position across refocus and rotation (#95)#96
vitofico merged 2 commits into
mainfrom
fix/reader-position-lost-on-refocus

Conversation

@vitofico

Copy link
Copy Markdown
Owner

Summary

Fixes the two ways the reader lost your place: coming back to the app, and rotating the device. Both come down to the same thing, the reader being re-paginated underneath you and then saving whatever text happened to land on screen.

Refocus. Readium was padding its own page container by the system-bar insets, and doing it late. The page rendered full height, then the first time the window regained focus the WebView re-measured against that padding and lost around 350px, re-paginating the chapter under the reader. In full-screen reading those bars are hidden and the page is deliberately full-bleed, so the padding was for bars that were not there; with full-screen reading off it was simply double-inset, since the reader already pads itself. Quire owns the reader's insets now, so Readium is told not to apply them (shouldApplyInsetsPadding = false). The reader also re-anchors on any change of its measured viewport rather than only the two changes it knew the names of, which is the backstop for the next one of these.

Rotation. Rotate to landscape and back and the reader did not come back to the page you left. It moved by a few paragraphs, and rotating again moved it again, always further. A measured trace on a marker book went from paragraph 18 to 31 to 38 over three rotate-and-return trips. This one predates the refocus bug.

The cause is what the reader was handing Readium to restore. Readium's published locator carries only locations.progression, a fraction of the way through the chapter, because Readium builds it from the EPUB positions service, which emits nothing else. Readium restores such a locator arithmetically, round(progression * numPages), and rotating rebuilds the very page grid that sum is denominated in (12 portrait pages against 23 landscape ones in the test book), while the sum runs before the rebuild has finished. Readium then publishes wherever it landed, that becomes the anchor for the next rotation, and the error compounds.

So the anchor is now a place in the document rather than a fraction. Readium restores a locator precisely, by looking an element up in the DOM and scrolling to it, whenever the locator carries text to match. Three things had to hold for that to actually stay put:

  1. The anchor has to be an element that begins on the page. Readium's own findFirstVisibleLocator returns the first element merely overlapping the page, usually one that began earlier, and restoring scrolls to where an element begins. Both round the same way, so they compound: anchoring that way still walked backwards, 22 to 21 to 20 to 18, all the way down to the chapter heading. PAGE_START_ANCHOR_JS mirrors Readium's walk and changes the test to "begins here", which makes capturing and restoring inverses of each other.
  2. The re-anchor has to happen once the layout is final. Re-pagination arrives in steps, and Readium snaps to page boundaries using a page width it caches in JavaScript, so an early re-anchor measures against the old grid. Re-anchoring only on the first step landed wrong, and inconsistently. It now re-anchors on every step and once more when the size stops changing, which is only safe to repeat because a DOM anchor lands in the same place every time.
  3. A rotation must not rewrite the anchor. Which element begins a page depends on the page's shape, so re-reading the anchor in the orientation you are only passing through replaces it with an earlier one, and the return leg honours that. Readium reports the restored position about half a second after the jump, and that arrives as an ordinary position update, so the anchor is held across that window.

A side effect worth having: the saved reading position now carries the DOM anchor too, so reopening a book lands on the exact page rather than near it.

Verification

On an emulator, against a generated book whose every sentence carries its own paragraph marker, comparing screenshots before and after.

Check Result
Rotation round trips, full-screen reading off (10 positions x 2, plus 8 consecutive from one position) every portrait screenshot byte-identical to the pre-rotation one, no accumulation
Rotation round trips, full-screen reading on (4 positions x 2) identical
Background and resume, full-screen reading off (3 cycles) page unchanged
Background and resume, full-screen reading on (3 cycles) page unchanged
Same protocol before the fix paragraph 18 to 31 to 38 to 31

Unit tests: 941 passing. The new ones were mutation-checked, each guard was reverted in turn and the corresponding test fails without it.

Component(s)

  • Android app
  • Sync server
  • Documentation
  • CI / build

Checklist

  • Tests added or updated (or N/A — explain)
  • Docs updated in the same PR (or N/A) — N/A, no user-facing docs for this
  • If Android UI changed: screenshots attached — N/A, no visual change; the behaviour is verified by screenshot comparison described above
  • If server schema changed: Alembic migration included — N/A
  • If core/identity changed: matching change on the other side + fixtures updated — N/A
  • No new third-party SDKs / analytics / telemetry
  • Commit messages follow gitmoji + conventional commits

Related issues

Closes #95

Readium's EPUB navigator pads its own page container by the system-bar
insets (Configuration.shouldApplyInsetsPadding, on by default). Quire
already owns the reader's insets — full-screen reading is deliberately
edge-to-edge, and with it off the whole reader subtree is padded by
WindowInsets.systemBars — so that padding is either for bars that are
hidden or a second helping of the same inset.

It also arrived late. The page rendered at full height, then the first
time the window regained focus the WebView finally re-measured against
the padding and lost ~350px (measured: CoordinatorLayout pad=247/105,
R2WebView 1080x2424 -> 1080x2072@247). That re-paginated the chapter, so
the reader came back to a different page, and because the resize happened
inside Readium's own view tree nothing here saw it coming: the
post-re-pagination locator was published and written over the saved
position, making the wrong page permanent.

Turn that padding off, and harden the re-anchor while we're at it. It
used to be armed only for resizes the reader could name — rotation and
the full-screen-reading toggle — which is why an unnamed one did this
much damage. ReaderViewModel.onViewportChanged now arms on the measured
viewport instead, covering causes we haven't thought of; seek/goTo clear
a pending anchor so an explicit jump isn't undone by one.

Verified on an emulator with a 5-chapter fixture: full-screen reading on,
five screenshots across four background/resume cycles are byte-identical
(before: drifted, with a 247px gap that stuck); off, three cycles hold
position with a single correct inset.
Rotate to landscape and back and the reader did not come back to the page you
left. It moved by a few paragraphs, and rotating again moved it again, always
further, so a few round trips could leave you a screen or two from where you
were reading. A measured trace on a marker book went from paragraph 18 to 31 to
38 over three rotate-and-return trips. This is separate from the refocus bug in
the previous commit and predates it.

The reader put itself back by handing Readium the locator Readium had last
published. That locator carries only `locations.progression`, a fraction of the
way through the chapter, because Readium builds it from the EPUB positions
service, which emits nothing but a progression and a position index. Readium
restores such a locator arithmetically: `round(progression * numPages)`, then
jump to that page. Rotating rebuilds the page grid the arithmetic is denominated
in (12 portrait pages against 23 landscape ones in the test book) and the sum
runs while the grid is still being rebuilt, so the page it picks is only loosely
related to the page you left. Readium then publishes wherever it landed, that
becomes the anchor for the next rotation, and the error compounds.

So anchor on the document instead of on a fraction. Readium restores a locator
precisely, by looking an element up in the DOM and scrolling to it, whenever the
locator carries text to match, and it will tell you which element is on screen.
Three things had to be true for that to actually hold still:

The anchor has to be an element that *begins* on the page. Readium's own
`findFirstVisibleLocator` returns the first element merely *overlapping* the
page, which is usually one that began earlier, and restoring scrolls to where an
element begins. Both round the same way, so they compound: anchoring that way
still walked backwards, 22 to 21 to 20 to 18, down to the chapter heading.
PAGE_START_ANCHOR_JS mirrors Readium's walk and changes the test to "begins
here", which makes capture and restore inverses of each other.

The re-anchor has to happen when the layout is final. Re-pagination arrives in
steps and Readium snaps to page boundaries using a page width it caches in
JavaScript, so an early re-anchor measures against the old grid. Anchoring only
on the first step landed wrong, and inconsistently. It now re-anchors on every
step and once more when the size stops changing, which is safe to repeat only
because a DOM anchor lands in the same place every time.

A rotation must not rewrite the anchor. Which element begins a page depends on
the page's shape, so re-reading the anchor in the orientation you are only
passing through replaces it with an earlier one and the return leg honours that.
Readium reports the restored position about half a second after the jump, which
arrives as an ordinary position update, so the anchor is held across that window.

Verified on an emulator against a book whose every sentence carries its own
paragraph marker, comparing screenshots. With full-screen reading off, ten
starting positions over two round trips each, plus eight consecutive round trips
from one position: every portrait screenshot byte-identical to the one before
rotating, and no accumulation. With full-screen reading on, four positions over
two round trips each, likewise identical. The already-fixed refocus behaviour
still holds: three background-and-resume cycles in each mode leave the page
untouched.

The saved reading position now carries the DOM anchor too, so reopening a book
lands on the exact page rather than near it.
@vitofico
vitofico merged commit 0e4fe24 into main Aug 18, 2026
5 checks passed
@vitofico
vitofico deleted the fix/reader-position-lost-on-refocus branch August 18, 2026 23:38
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.

Fullscreen readin mode

1 participant