fix(mobile): make swipe back smoother

Merged
OC
2d ago
+541-699

Makes the native scroll-snap pager the mobile edge back swipe and deletes the pointer-driven one. There is no flag and no second path.

What changed

  • MobileStack renders its layers as pages of a scroll-snap-type: x mandatory scroller. Every page is position: sticky; left: 0 and the current page is last, so a swipe moves only the current card while the page below stays put. useBackPager pins scrollLeft to the last page before paint whenever the page list changes, and on resize.
  • Stacked screens get touch-action: pan-y through a zero-specificity rule in @layer base. A 20px full-height edge strip (data-pager-edge, touch-action: pan-x) is the only place where a horizontal drag reaches the pager. Horizontal scrollers carry data-scroll-x (meowdown sets it on code blocks and math and Mermaid previews, prosekit/meowdown#752, released in 0.82.3; swipe rows set it too), and they and everything inside them opt back into auto with overscroll-behavior-x: contain.
  • A tap on the strip goes to what lies under it (elementsFromPoint and caretRangeFromPoint with the strip's pointer events off): fields focus, controls click, and the editor gets a caret.
  • watchScrollSettle commits the pop when a user scroll settles at scrollLeft <= 1. The revealed page drops inert and aria-hidden at once, before React re-renders it as current. A scroll that no finger, wheel, or mouse drag started pins the pager back.
  • A mouse drag works like a finger, but only from the edge strip (dragHandle). A plain click on the strip still forwards; the click that ends a drag does not.
  • The gesture scrim follows the scroll through linkOpacityToScroll. Button back, wiki-link push, and other programmatic push and pop keep the existing CSS keyframes.
  • The component doc comment states the stacked-screen rules: no position: fixed element inside a stacked screen (it would stay put while the card moves), and data-scroll-x for any horizontal scroller.

What was deleted

  • mobile/use-back-swipe.ts and its test (the pointer hook with its non-passive touchmove blocker, per-move setState, and settle transition).
  • The pointer render path in mobile-stack.tsx: the dragging and settling styles, the drag scrim, and the transitionend listener.
  • The four pointer back-swipe tests. The push, pop, tab, note-chain, and reduced-motion tests now run once, on the pager. lib/pointer-drag.ts stays (the image dismiss drag uses it).

What users feel

  • The card follows the finger on the compositor, so a busy main thread (a long note mounting, a sync batch, a save) no longer freezes the card mid-swipe. React does not render during the drag.
  • The commit point is the native snap: about half the width, or a fling. The old 40% distance and velocity rule is gone.
  • A swipe that starts anywhere but the left 20px scrolls the note vertically and never goes back. Horizontal scrolling inside notes keeps working.
  • A snap back can be caught by a new touch; the old settle transition could not.
  • Known limitation: a vertical drag that starts in the leftmost 20px (the edge strip) does not scroll the screen. The strip is a sibling of the screen's scroller, not its ancestor, so the drag cannot chain into it. Start a vertical drag anywhere else to scroll.

Before and after

Chromium 156.0.8078.4 (Playwright, headless, isMobile, 390x844), 4x CPU throttle, dev build, n = 7 per row after 1 warmup. Base b83dca13 (old hook; the old path is byte-identical at the PR's base 26f9058a), PR 9764ce22. Real CDP Input.dispatchTouchEvent gestures from (8, 420): pop = slow drag to x 330 and release; cancel = drag to x 100, hold 300ms, release; fling = 5 moves to x 150, 8ms apart. Light note: notes/sarah-chen.md (seed). Heavy note: a generated 3000-line note. "During" is touchStart to touchEnd; "after" is the 1.5s after touchEnd. LoAF max is the max over reps; other columns are p50. Script and task time come from CDP Performance.getMetrics deltas over the drag, divided by the delivered touchmove count. p95FrameMs and hitchTimeRatio use the perf HUD formula on rAF times from touchStart to touchEnd + 500ms. Correct means the expected outcome happened exactly once (one back() for a pop, none for a cancel).

note, gesturebuildLoAF max during (ms)LoAF total after (ms)React commits during dragscript ms / movetask ms / movep95FrameMshitchTimeRatiocorrect
light popbase055211.944.4316.811.57/7
light popPR0000.101.3516.813.97/7
heavy popbase5789211.928.9016.845.27/7
heavy popPR07800.169.4216.854.27/7
light cancelbase00181.975.0216.80.47/7
light cancelPR0000.131.5716.811.27/7
heavy cancelbase00182.0411.0016.818.77/7
heavy cancelPR0000.2015.6216.811.17/7
light flingbase05262.427.3716.723.47/7
light flingPR0000.192.1616.70.37/7
heavy flingbase598562.4124.1616.885.27/7
heavy flingPR07900.2514.7516.70.37/7
  • Script time per move drops about 10x and React commits during the drag drop to 0. LoAFs during the drag are gone.
  • The heavy note still costs main-thread task time (style, layout, paint) while it scrolls, and the pop still has one LoAF of about 80ms after release (the revealed daily re-render). The pager moves the drag off the main thread; it does not make the commit cheaper.
  • rAF metrics only describe the main thread. In Chromium they stay at the frame budget in both builds; they do not show compositor smoothness.
  • One behavior difference: a slow drag to x 100 released while the finger still moves (no hold) pops 7/7 with the pager and cancels 7/7 with the old hook. Chromium snaps a fling to the next snap point in its direction. WebKit on iOS may differ; check it on a device.

How to test by hand

On an iPhone (or iPad) build, open a note from Daily:

  1. Swipe right from the left edge past half and release: the note goes back to Daily. Swipe partway, stop, release: the note stays.
  2. Flick quickly from the left edge: it goes back. Start a new touch while the card snaps back: the card follows the finger again.
  3. Swipe right from the middle of the note: nothing goes back; vertical scrolling stays smooth. Diagonal drags scroll vertically.
  4. Tap near the left edge: the Back button (its left part sits under the strip), a text field, and the start of a text line still work (the caret lands at the line start).
  5. Scroll a wide code block, a table, a math or Mermaid preview sideways: it scrolls, and dragging past its left end does not go back.
  6. Repeat 1 in Settings, Graphs, Licenses, a note-to-note chain (lands on the previous note), and a cold note entry (lands on today).
  7. With a mouse or trackpad pointer (iPad or desktop browser at ?platform=ios): press on the left 20px and drag right: it goes back. Click the strip over the Back button: it goes back once. Drag on note text: it selects text and never swipes.
  8. Turn on Reduce Motion: button back and push are instant; the swipe still works.
  9. On a long note or Settings, drag vertically starting inside the leftmost 20px: the screen does not scroll (known limitation). The same drag starting further right scrolls normally.

Written by Claude Opus 5.5.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Summary by Sourcery

Adopt native scroll-snap paging for mobile edge back navigation to improve gesture responsiveness and preserve nested scrolling and underlying interactions.

New Features:

  • Replace the mobile pointer-driven back swipe with a native scroll-snap pager for edge back navigation, including touch and mouse gestures.
  • Preserve interaction with controls, fields, editors, and horizontal scrollers beneath the edge gesture strip.

Bug Fixes:

  • Prevent unintended back navigation from non-edge gestures, programmatic scrolls, and horizontal scrolling within note content.
  • Ensure the revealed screen becomes interactive immediately when a swipe pop settles.

Enhancements:

  • Move swipe progress and settling to browser scrolling so React does not render during the gesture and the pager remains responsive under main-thread load.
  • Retain existing programmatic navigation animations while simplifying stack-layer transitions and gesture handling.

Documentation:

  • Document mobile stacked-screen constraints and requirements for fixed elements and horizontal scrollers.

Tests:

  • Update mobile navigation coverage for native pager swipes, cancellation, settling, resizing, mouse gestures, tap forwarding, and note navigation scenarios.

Chores:

  • Remove the pointer-based back-swipe hook and its dedicated tests.

Summary by CodeRabbit

  • New Features
    • Mobile navigation now supports edge-to-edge back swipes using horizontal page scrolling. A narrow edge area also lets you tap underlying fields and controls while navigating stacked screens.
  • Bug Fixes
    • Back navigation now handles touch and mouse gestures more consistently, including quick swipes, interrupted gestures, and late scrolling events. Scrolling within mobile pages remains vertical by default, while designated horizontal content can still scroll sideways.