Skip to content

Add mobile resume-position restore with a compact Welcome back card#100

Merged
Renakoni merged 3 commits into
mainfrom
feature/editor-resume-position
Jul 12, 2026
Merged

Add mobile resume-position restore with a compact Welcome back card#100
Renakoni merged 3 commits into
mainfrom
feature/editor-resume-position

Conversation

@Renakoni

Copy link
Copy Markdown
Owner

What

A production "resume where you left off" flow for the mobile editor: the app remembers the exact body position where the user stopped reading or editing and, on the next open of the unchanged document, offers a compact non-modal "Welcome back" card below the top bar. Tapping the card jumps to the saved position; nothing ever scrolls automatically.

How

Capture (features/editor/resumePosition.ts)

  • The anchor is structural, not pixel-based: the index into the full top-level Muya block sequence (front matter included — the direct .mu-container children map 1:1 onto muya.getState() blocks) plus the viewport-top ratio inside that block.
  • The in-memory anchor updates after scroll settling (250 ms quiet window), never per scroll event, and persists only through the existing exit/lifecycle paths (closeEditorToHome, lifecycle flush, document replacement, unmount). A visit that never scrolls leaves the stored position untouched.

Persistence (lib/resumePositions.ts)

  • Dedicated versioned store marktext-for-android:resume-positions, keyed by stable document identity (local-draft:{id} / android-document:{sourceUri}), LRU-bounded to 30 records by capture time; orphans age out through the cap.
  • Record: { schemaVersion, capturedAt, normalizedLength, markdownSha256, topBlockIndex, topBlockRatio, displayText }. No cursor snapshots, DOM paths, runtime ids, or speculative fields.
  • Position writes never touch document/draft/recent records, never dirty the Markdown, never change content timestamps or list ordering, and never surface as saves (asserted by e2e).

Validation

  • SHA-256 (WebCrypto) over canonicalized Markdown (LF + trailing-newline neutralization so save-pipeline churn cannot cause false negatives), with normalized length as the fast pre-check. Stored indexes/ratios are strictly validated and clamped.
  • Any mismatch or resolution failure silently discards the opportunity and cleans up the stale record. No prefix/text/approximate relocation exists in this implementation; displayText is presentation-only.

Restore + stabilization

  • The card offer requires: exact hash match, resolvable block index, and a target meaningfully below the fold (≥1.5 viewports).
  • Tap → instant unanimated scroll → bounded stabilization window: a ResizeObserver on .mu-container and the anchor block re-pins the target when displacement exceeds 8 px, stopping on layout quiescence (600 ms), hard cap (4 s), first trusted user input, editor destruction/replacement, or generation cancellation. Observers and listeners always disconnect; no permanent scroll-spy, no style/padding mutation.
  • The card dismisses on meaningful scroll, edit, Search/Outline/menu/toolbar/link-sheet via the App orchestration layer, auto-timeout (10 s), explicit close, or document replacement. It is non-modal and takes no Android Back priority. Enter/leave motion respects prefers-reduced-motion.

Out of scope (by design): caret/selection restore, relocation after external edits, contextual text matching, cross-device sync, automatic navigation on open, permanent scroll tracking, Muya changes.

Verification

  • Unit: 410 passing (26 new — store round-trip/validation/LRU/corruption, SHA-256 vector, canonicalization, anchor math incl. capture↔restore inverse).
  • E2E (real Muya): 96 passing (9 new — exit-capture → card → exact landing without auto-scroll or focus; silent discard after external edit incl. record cleanup; near-top suppression; scroll/close/edit/competing-surface dismissal; drafts-storage byte-identity across activation; record survival on a scroll-less revisit).
  • Lint, typecheck, build.
  • API 35 emulator smoke over CDP (8/8): position persisted with real SHA-256 on exit, card offered with CJK snippet ellipsis, scrollTop 0 before tap, jump landed at the captured offset, focus state unchanged and mInputShown=false after the jump, user scroll dismissed the card.

Emulator screenshot shows the compact chip (icon + "Welcome back" + one-line ellipsized snippet + close) floating under the top bar without obscuring the editor.

Renakoni added 2 commits July 11, 2026 09:51
Return the user to the exact body position where they previously stopped,
gated by strict content validation so a convincing but incorrect jump is
impossible.

- Capture: a structural anchor (full top-level Muya block index including
  front matter, plus the viewport ratio inside that block) updates in
  memory on scroll settle and persists only through the existing
  document-exit and app lifecycle paths.
- Store: a versioned, LRU-bounded localStorage store
  (marktext-for-android:resume-positions) keyed by stable document
  identity (local draft id / android-document source URI). Position writes
  never touch document, draft, or recent-document records, never dirty the
  Markdown, and never appear as saves.
- Validation: SHA-256 of canonicalized Markdown plus a length fast check.
  Any mismatch or resolution failure silently discards the opportunity;
  displayText is presentation-only.
- Restore: no automatic scrolling on open. A compact non-modal card below
  the top bar offers the jump; tapping scrolls instantly without focusing
  Muya or opening the keyboard, then runs a bounded ResizeObserver
  stabilization window (quiescence, hard cap, trusted user input, or
  generation cancellation) to re-pin the anchor through late layout
  shifts. The card dismisses on scroll, edit, competing editor surfaces,
  auto-timeout, or explicit close, and never takes Android Back priority.

Verified: 410 unit tests (26 new), 96 e2e (9 new real-Muya resume specs),
lint, typecheck, and an API 35 emulator smoke over CDP (capture on exit,
card offer without auto-scroll, exact jump landing, focus/IME untouched,
scroll dismissal).
…t settle

Review round 1 fixes, one per finding:

1. Capture races and structurally stale anchors: persistNow() no longer
   depends on a debounced scroll-settle snapshot. It flushes pending editor
   content, then synchronously reads the live viewport anchor from the DOM
   at each persist point, so an exit inside any quiet window keeps the
   latest position and a post-edit exit can never pair a new hash with a
   pre-edit block index. The scroll listener now only tracks card dismissal
   and a positionTouched flag; a session without scrolling or editing still
   leaves the stored record untouched.

2. Latest-capture-wins persistence: capturedAt is stamped when the anchor
   is snapshotted (before hashing), and every write carries a per-document
   monotonic token checked after the digest resolves — an older SHA-256
   completing late can never overwrite a newer capture, including across
   session resets. persistNow() returns the write's promise.

3. Bounded initial-layout settle before eligibility: startForOpenedDocument
   waits for .mu-container to stop resizing (350 ms quiet window, 3 s hard
   cap) before validating and measuring the target distance. A quiet window
   alone cannot see a network-stalled image (no resize events), so the
   settle also refuses to resolve while Muya marks an image probe as
   pending (mu-image-loading) or an <img> is incomplete. The offer is also
   skipped when the user scrolled or edited before evaluation finished.

New regressions: unit — deterministic deferred-hash ordering (newer resolves
first, older last; and across a session reset), live-capture-at-persist, and
edit-only-session persistence; e2e — immediate exit inside the quiet window,
second-scroll-wins on immediate exit, tall-block split without re-scroll
landing at the exit position, and a delayed tall image above the target that
must not suppress the card.

Verified: 414 unit tests, 100 e2e, lint, typecheck, API 35 emulator smoke
(8/8) plus a device race smoke: scrollTop=4200 and Back issued in the same
frame still persists the latest anchor and the reopened jump lands at 4200.
@Renakoni

Copy link
Copy Markdown
Owner Author

All three findings addressed in 09f1234.

1. Quiet-window race + structurally stale anchors (merge-blocking) — capture no longer has a debounced snapshot to race against. persistNow() now: validates the session key → flushes pending editor content (getMarkdown with flush) → synchronously reads the live viewport anchor from the DOM → snapshots key/markdown/anchor/capturedAt → hashes and writes. The scroll listener only tracks card dismissal and a positionTouched flag (scroll or edit); an untouched session still leaves the stored record alone, and a post-edit exit can only ever persist an anchor read from the post-edit DOM in the same tick as the hashed Markdown. Reproduced your repro before the fix and added it as a regression: scroll to 3000 and exit with zero settle time now stores and restores the position (e2e), plus a device-level race smoke (scrollTop=4200 and Back issued in the same frame → record captured, reopened jump lands at 4200).

2. Unordered fire-and-forget writescapturedAt is stamped at snapshot time, before hashing, so LRU recency reflects capture order. Every write carries a per-document monotonic token checked after the digest resolves; the token map deliberately survives resetForNewDocument(), so a snapshotted outgoing-document write may complete after editor destruction but can never overwrite a newer capture for the same key. persistNow() returns the write's promise (exit paths currently discard it, so Android Back never blocks). Deterministic unit tests use an injected deferred hash: newer request resolves first / older last, and the same across a session reset — the newer capture wins both times.

3. Eligibility judged against transient layoutstartForOpenedDocument now awaits a bounded initial-layout settle on .mu-container (350 ms quiet window, 3 s hard cap, generation-cancelled, observer always disconnected) before validation and the distance check. One addition beyond a plain quiet window: a network-stalled image produces no resize events, so the settle also refuses to resolve while Muya flags a pending image probe (mu-image-loading) or an incomplete <img> — otherwise a 900 ms image above the target still slipped past a 350 ms quiet window (the new e2e caught exactly that on the first attempt). The offer is additionally skipped if the user scrolled or edited before evaluation completed. New real-Muya e2e: a route-delayed 3200-px image above the saved target must not suppress the card, with a page reload between sessions so neither the HTTP cache nor Muya's in-session image cache short-circuits the slow load.

Verification: 414 unit (34 resume-related), 100 e2e (13 resume specs incl. the four new regressions: immediate exit in the quiet window, second-scroll-wins, tall-block split without re-scroll landing at the exit position, delayed-image eligibility), lint/typecheck/build, API 35 emulator smoke 8/8 (mInputShown=false after the jump) plus the same-frame race smoke above.

…on removal

1. Post-jump stabilization now shares the initial settle's pending-image
   awareness: when its quiet window elapses while a Muya image probe
   (mu-image-loading) or an incomplete <img> is still in flight, the timer
   re-arms instead of disconnecting — a network wait produces no resize
   events, so quiet alone is not trustworthy. The eventual load's reflow
   re-pins the anchor through the observer; the 4 s hard cap and trusted
   user input still bound and cancel the window.

2. A hash-mismatch during opening-time validation no longer removes the
   store entry unconditionally. The removal is identity-checked against a
   re-read of the store (capturedAt + sha256): if a lifecycle capture
   landed a newer, valid record while the stale record's hash was still
   computing, the removal is skipped; if a newer write completes after the
   removal, it re-creates the entry — both orderings preserve the newest
   capture.

New regressions:
- e2e: an image slower than the initial-settle hard cap — the card is
  offered while the image is still pending, the first landing is measured
  ~3200 px short on the transient layout, and stabilization must re-pin to
  the captured position when the image lands (pre-fix, stabilization
  disconnected after 600 ms of quiet and left the user displaced).
- unit: a stale validation mismatch racing a concurrent persistNow write —
  the newer record must survive regardless of completion order.

Verified: 415 unit, 101 e2e, lint, typecheck, API 35 emulator smoke 8/8
plus the same-frame scroll+Back race smoke (mInputShown=false).
@Renakoni

Copy link
Copy Markdown
Owner Author

Both round-2 findings addressed in 9730f67.

[P2] Stabilization dying under a resize-free network wait — the stabilization quiet handler now uses the exact same pending-awareness as the initial settle: when the 600 ms quiet window elapses while mu-image-loading or an incomplete <img> is present under .mu-container, the timer re-arms instead of stopping. The load's eventual reflow re-pins through the observer; the 4 s hard cap, trusted-input cancellation, and generation checks are unchanged, so the window stays bounded.

The new e2e covers the exact sequence from the finding: the image is served fast on the capture session (anchor measured on the full layout) and slower than the 3 s initial-settle cap on reopen, so the card becomes actionable while the image is still in flight. The test asserts the first landing is ~3200 px short on the transient layout, then that stabilization converges to the captured position once the image lands — pre-fix this fails exactly as described (stabilization disconnects after 600 ms of quiet, the user stays displaced).

One debugging note worth recording: while building this test I initially concluded Muya leaves mu-image-loading behind after a successful load — that was wrong. The only <img> matching my probe was the heading's copy-link icon; the real content image only enters the DOM when Muya's probe resolves, and the loading class is removed at the same time. The pending predicate needed no change; the test's capture gate did (it now waits for .mu-inline-image img instead of a scrollHeight heuristic that an image-less layout could already satisfy).

[P2] Stale validation deleting a newer record — mismatch removal is now identity-checked: before removePosition, the store is re-read and the entry is deleted only if it is byte-for-byte the record this validation read (capturedAt + markdownSha256). If a lifecycle capture landed a newer record while the stale hash was computing, the removal is skipped; if a newer in-flight write completes after the removal, the write re-creates the entry — the newest capture survives under both orderings, complementing the existing per-document write tokens (which order write-vs-write but not removal-vs-write, as you noted). New deterministic unit test drives exactly the finding's interleaving: validation reads the stale record, a persistNow write lands mid-hash, and the newer record must remain.

Verification: 415 unit, 101 e2e, lint/typecheck/build, API 35 emulator smoke 8/8 plus the same-frame scroll+Back race smoke (mInputShown=false after the jump), all rerun on the rebuilt APK.

@Renakoni Renakoni merged commit c7b4149 into main Jul 12, 2026
5 checks passed
@Renakoni Renakoni deleted the feature/editor-resume-position branch July 12, 2026 02:05
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.

1 participant