fix(mobile): make row swipe actions smoother
Swipe-action rows (Tasks tab and All tab) now run on native horizontal scrolling instead of a pointer-driven transform.
What changed
- New
SwipeRow(mobile/swipe-row.tsx): asnap-x snap-mandatoryoverflow-x-autorow. The row content is the closed snap point, a zero-widthsnap-endanchor is the open snap point, and the actions sit under the content withsticky right-0. The browser runs the drag, the release decision, and the snap.watchScrollSettlereports where the row came to rest (>= actionWidth / 2is open). Whenrevealedchanges from outside, the row scrolls itself (instanton mount and under reduced motion, elsesmooth), unless a finger is on it. Every row hasdata-scroll-x(the horizontal-scroller marker meowdown also sets) andoverscroll-x-contain, so a row scroll never chains into an outer horizontal scroller. No leading actions, no full swipe. - Mouse drag works like a finger (from
watchScrollSettle): a press that moves past 4 px drags the row, and the release goes to the nearer end. SwipeableNoteRowandMobileTaskRowuseSwipeRow. A tap on an open row still only closes it.- New
RevealedRowStore(mobile/revealed-row.ts): the one open row of a list, keyed by note path orgetTaskKey. Each row subscribes to its own key withuseSyncExternalStore, so opening or closing a row re-renders two rows, not the whole list.NoteRowListandMobileTasksown the store and clear it on list scroll, as before.
What was deleted
mobile/use-row-swipe.ts(direction threshold, 1:1transform, velocity projection, custom rubber band, settle transition, drag-click suppression).swipeandtranslateXfromtest-utils/swipe.ts, and the tests of pointer-only behavior (direction lock, abandoned pre-threshold touch).lib/pointer-drag.tsstays: the image lightbox drag uses it.
What users feel
- The row follows the finger on the compositor, at the display refresh rate, also while the main thread is busy.
- No freeze after release on the Tasks tab. Before, opening or closing a task row re-rendered every task row (836 here): about 0.5 s at 4x CPU. Now no long frame follows the gesture.
- Release physics, the snap curve, and the edge bounce are the system scroll physics. Release past half still opens the row.
- iPad trackpad, mouse, and Apple Pencil can open rows, as in native lists.
Before / after
Setup: Chromium 156.0.8078.4 (Playwright headless shell), 4x CPU throttle, real CDP Input.dispatchTouchEvent touches 16 ms apart, viewport 390x844, dev build with the seeded graph plus two 3000-line notes (836 open tasks; the Tasks list is not virtualized). n = 5 per cell after 1 warmup, p50. Base 975713e5 (old hook). PR ad9873bf. The head c30b502c has the same mobile code; it differs only in a test and in the base branch's mouse-drag fixes, which the touch gestures here do not use. The middle value is the first commit alone (SwipeRow without RevealedRowStore, 14c63faa on an earlier tip of the base branch).
Window: touchStart to touchEnd + 1500 ms. "Release LoAF" is the longest long animation frame that ends after touchEnd. "Commits" are React commits during the moves / after touchEnd. "Script ms per move" comes from separate traced reps: CPU samples that are not idle, root, or program, from touchStart to the last move, divided by the number of moves. p95FrameMs and hitchTimeRatio follow perf-hud/metrics.ts on main-thread rAF times. Row gestures: 150 px left from a closed row, or 150 px right from an open row, 20 moves. List scroll: 450 px up in 10 moves, then its momentum.
Each cell: base → SwipeRow only → this PR.
| Scenario | Release LoAF ms | LoAF max ms (worst rep) | Commits during / after | Script ms per move | p95FrameMs | hitchTimeRatio |
|---|---|---|---|---|---|---|
| Task row open | 521 → 300 → 0 | 521 (541) → 300 (311) → 0 (0) | 1 / 1 → 1 / 1 → 0 / 1 | 0.26 → 0.29 → 0.21 | 16.8 → 16.8 → 16.8 | 226.8 → 135.6 → 8.9 |
| Task row close | 500 → 327 → 0 | 500 (507) → 327 (334) → 0 (0) | 1 / 1 → 0 / 2 → 0 / 1 | 0.28 → 0.19 → 0.14 | 16.8 → 16.8 → 16.7 | 218.1 → 149.4 → 8.8 |
| Note row open (All) | 0 → 0 → 0 | 0 (0) → 0 (0) → 0 (0) | 1 / 1 → 0 / 2 → 0 / 1 | 0.11 → 0.16 → 0.09 | 16.8 → 16.7 → 16.7 | 1.6 → 1.5 → 1.5 |
| Note row close (All) | 0 → 0 → 0 | 0 (0) → 0 (0) → 0 (0) | 1 / 1 → 0 / 2 → 0 / 1 | 0.17 → 0.10 → 0.20 | 16.8 → 16.8 → 16.7 | 1.5 → 1.6 → 1.5 |
| Tasks list scroll (836 rows) | 0 → 0 → 0 | 0 (0) → 0 (62) → 0 (0) | 0 / 0 → 0 / 0 → 0 / 0 | 0.13 → 0.36 → 0.20 | 16.7 → 16.8 → 16.7 | 1.5 → 10.3 → 1.5 |
Every row gesture ended in the expected state on all builds.
p95FrameMscannot show the release LoAF: the window has about 100 frames and only one is long. The LoAF columns andhitchTimeRatioshow it.- On base the re-render runs on
pointerup. WithSwipeRowalone it moves to the settle report and costs about 300 ms.RevealedRowStoreremoves it. - Script per move was already small with the old hook (it wrote
transformdirectly, with no React work). With native scroll no JS runs on the drag path except the passivescrolllistener. - The All tab is virtualized (about 30 rows), so note rows had no long frame before either.
- Chromium only; WKWebView is not measured.
How to test by hand
On iPhone, and on iPad with touch, trackpad, and mouse:
- Tasks tab: swipe a row left. Note and Delete show under the row. Release before half: the row closes. Release past half, or flick: it opens.
- Tap the open row: it closes, with no edit sheet and no checkbox toggle. Open it again and tap Note (opens the source note) or Delete (removes the task).
- Open one row, then swipe another one (also in another group): the first row closes. Scroll the list: the open row closes.
- With an open row, tap another row at once. Nothing freezes (before, the screen stalled for about 0.5 s).
- All tab: do the same with note rows (Pin / Unpin, Delete with the confirm drawer). A daily note shows only Pin.
- Open a note row, scroll it far off screen and back: it comes back closed.
- Mouse or trackpad (iPad, or the browser preview at
/?platform=ios): click-drag a row left past half and release: the row opens. Drag less: it closes. A plain click still opens the note or the edit sheet. - Reduce Motion on: a close from code (tap, list scroll) is instant.
Summary by Sourcery
Use native horizontal scrolling and shared per-row state to make mobile swipe actions smoother and avoid expensive list-wide updates.
New Features:
- Replace mobile row swipe gestures with native horizontal scrolling and snap-based reveal behavior for task and note actions.
- Support mouse and trackpad dragging alongside touch interactions while preserving row tap and action semantics.
Bug Fixes:
- Prevent list-wide re-renders and post-gesture freezes when opening or closing task rows.
- Ensure scrolling the list closes revealed rows and prevent horizontal scroll chaining into parent scrollers.
Enhancements:
- Add shared revealed-row state so only affected rows update when the open row changes.
- Update swipe interaction tests to verify native scrolling, settling, external state changes, mouse dragging, and list-scroll behavior.
Tests:
- Add focused coverage for native swipe-row settling, programmatic positioning, touch interruption, and pointer interactions.
- Revise note and task screen tests to assert scroll positions and native row behavior.
Chores:
- Remove the custom pointer-driven row swipe implementation and obsolete transform-based test helpers.
Summary by CodeRabbit
- New Features
- Mobile note and task rows now reveal actions with horizontal scrolling and snap into open or closed positions.
- Opening one row closes any other open row.
- Bug Fixes
- Tapping a row with its actions open closes them without opening the note or task.
- Scrolling the list closes open rows, and rows stay in place during active touch interactions.
- Choosing a row action closes its revealed actions before carrying it out.
- Pressing Enter or Space on an open task row closes it instead of starting an edit.