fix(mobile): make swipe back smoother
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
MobileStackrenders its layers as pages of ascroll-snap-type: x mandatoryscroller. Every page isposition: sticky; left: 0and the current page is last, so a swipe moves only the current card while the page below stays put.useBackPagerpinsscrollLeftto the last page before paint whenever the page list changes, and on resize.- Stacked screens get
touch-action: pan-ythrough 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 carrydata-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 intoautowithoverscroll-behavior-x: contain. - A tap on the strip goes to what lies under it (
elementsFromPointandcaretRangeFromPointwith the strip's pointer events off): fields focus, controls click, and the editor gets a caret. watchScrollSettlecommits the pop when a user scroll settles atscrollLeft <= 1. The revealed page dropsinertandaria-hiddenat 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: fixedelement inside a stacked screen (it would stay put while the card moves), anddata-scroll-xfor any horizontal scroller.
What was deleted
mobile/use-back-swipe.tsand its test (the pointer hook with its non-passivetouchmoveblocker, per-movesetState, and settle transition).- The pointer render path in
mobile-stack.tsx: the dragging and settling styles, the drag scrim, and thetransitionendlistener. - 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.tsstays (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, gesture | build | LoAF max during (ms) | LoAF total after (ms) | React commits during drag | script ms / move | task ms / move | p95FrameMs | hitchTimeRatio | correct |
|---|---|---|---|---|---|---|---|---|---|
| light pop | base | 0 | 55 | 21 | 1.94 | 4.43 | 16.8 | 11.5 | 7/7 |
| light pop | PR | 0 | 0 | 0 | 0.10 | 1.35 | 16.8 | 13.9 | 7/7 |
| heavy pop | base | 57 | 89 | 21 | 1.92 | 8.90 | 16.8 | 45.2 | 7/7 |
| heavy pop | PR | 0 | 78 | 0 | 0.16 | 9.42 | 16.8 | 54.2 | 7/7 |
| light cancel | base | 0 | 0 | 18 | 1.97 | 5.02 | 16.8 | 0.4 | 7/7 |
| light cancel | PR | 0 | 0 | 0 | 0.13 | 1.57 | 16.8 | 11.2 | 7/7 |
| heavy cancel | base | 0 | 0 | 18 | 2.04 | 11.00 | 16.8 | 18.7 | 7/7 |
| heavy cancel | PR | 0 | 0 | 0 | 0.20 | 15.62 | 16.8 | 11.1 | 7/7 |
| light fling | base | 0 | 52 | 6 | 2.42 | 7.37 | 16.7 | 23.4 | 7/7 |
| light fling | PR | 0 | 0 | 0 | 0.19 | 2.16 | 16.7 | 0.3 | 7/7 |
| heavy fling | base | 59 | 85 | 6 | 2.41 | 24.16 | 16.8 | 85.2 | 7/7 |
| heavy fling | PR | 0 | 79 | 0 | 0.25 | 14.75 | 16.7 | 0.3 | 7/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:
- Swipe right from the left edge past half and release: the note goes back to Daily. Swipe partway, stop, release: the note stays.
- Flick quickly from the left edge: it goes back. Start a new touch while the card snaps back: the card follows the finger again.
- Swipe right from the middle of the note: nothing goes back; vertical scrolling stays smooth. Diagonal drags scroll vertically.
- 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).
- 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.
- Repeat 1 in Settings, Graphs, Licenses, a note-to-note chain (lands on the previous note), and a cold note entry (lands on today).
- 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. - Turn on Reduce Motion: button back and push are instant; the swipe still works.
- 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](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
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.