Dragging a card on mobile is VERY sluggish and laggy #64

Closed
opened 2026-07-28 23:38:22 +00:00 by Grandiras · 2 comments
Owner
No description provided.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/164); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-64/run-164.md) — updating every ~5 minutes while the task runs.
Collaborator

Pushed to claude/issue-64.

What was wrong

Two SortableJS options that are unremarkable on a desktop are wired to the raw input stream on touch.

scroll (autoscroll) and emptyInsertThreshold (empty-list drop detection) both take their coordinates from dragover when a native HTML5 drag is available. On a touch device there is no native drag, so the library binds the same handlers to pointermove / touchmove instead — and a current phone samples touches 120–240 times a second:

  • scroll opens every call with document.elementFromPoint, which flushes style and layout for the whole board and then hit-tests it.
  • emptyInsertThreshold puts every empty Sortable into a document-level handler that measures each one — and the pagination bar contributes one permanently-empty Sortable per column.

That asymmetry is why the same drag was fine on a desktop and crawled on a phone. In a CPU profile of a drag, elementFromPoint alone was the single largest identifiable cost, ~21% of non-idle time.

What changed

  • emptyInsertThreshold: 0 on the drop tiles. It cost them a 20px halo; the tiles are 64px tall while a drag is on.
  • scroll: false everywhere, with the capability reimplemented in sortable-interop.js as dragScroll (~45 lines). It reads nothing from the DOM while the finger is moving: containers are measured when the drag starts, the move handler only records a coordinate, and a rAF loop compares the two. It also listens for dragover, because a mouse drag fires no pointer move at all while it is in flight — without that, desktop autoscroll would have silently died.
  • will-change: transform on .card-dragging. The library rewrites that clone's transform every frame; off its own compositor layer that repaints two soft shadows every frame with it.

What I verified

No container engine or dotnet in this runner, so the compose stack and the Playwright suite could not be started. Instead I built an offline harness that loads the repo's real app.css, sortable.min.js, sortable-interop.js and board-pagination.js into a board of 4 columns × 14 cards, and drives real touch drags over CDP against an emulated Pixel 7 (confirmed nativeDraggable === false, i.e. the actual mobile code path).

Traced, 10× CPU throttle, median of 7 runs:

before after
script 1442 ms 979 ms
style recalc 292 ms 180 ms
paint 361 ms 214 ms
total main-thread 4086 ms 2990 ms
elementFromPoint 238 ms 90 ms

Functional checks, all passing: reorder within a column still fires OnCardMoved at the right index; a drop on a pagination tile still moves the card and strands nothing in the bar; a card held at a column's bottom edge scrolls it; a card held at the board's right edge scrolls sideways; the frame loop stops on drop; and a dragover-only coordinate drives the loop (the desktop path).

Tests and docs

  • board.spec.js pins both Sortable options and the will-change promotion.
  • mobile.spec.js gains a Touch drag group that drives a real CDP touch drag and asserts a held card scrolls its column — the behaviour that moved from the library into this repo. It creates its own card so teardown can reclaim it. These two are unrun — they need the compose stack. Every assertion in them was checked true against the real files in the harness, but the live-board wiring around them has not executed.
  • CLAUDE.md records the desktop/touch asymmetry, why dragScroll reads nothing per move, and the two non-obvious rules in it.

One thing I deliberately did not do: animation: 0 measured another ~15% off script time, but it costs the reorder slide on every platform, and the wins above don't need it.


⏱ 43m 38s · 126 turns · tokens: 210.3k in / 96.2k out (+17328.1k cache-read)
📜 Full session transcript
→ Opened PR: #67

Pushed to **`claude/issue-64`**. ## What was wrong Two SortableJS options that are unremarkable on a desktop are wired to the raw input stream on touch. `scroll` (autoscroll) and `emptyInsertThreshold` (empty-list drop detection) both take their coordinates from `dragover` when a native HTML5 drag is available. On a touch device there is no native drag, so the library binds the same handlers to `pointermove` / `touchmove` instead — and a current phone samples touches 120–240 times a second: - **`scroll`** opens every call with `document.elementFromPoint`, which flushes style and layout for the whole board and then hit-tests it. - **`emptyInsertThreshold`** puts every *empty* Sortable into a document-level handler that measures each one — and the pagination bar contributes one permanently-empty Sortable per column. That asymmetry is why the same drag was fine on a desktop and crawled on a phone. In a CPU profile of a drag, `elementFromPoint` alone was the single largest identifiable cost, ~21% of non-idle time. ## What changed - **`emptyInsertThreshold: 0`** on the drop tiles. It cost them a 20px halo; the tiles are 64px tall while a drag is on. - **`scroll: false`** everywhere, with the capability reimplemented in `sortable-interop.js` as `dragScroll` (~45 lines). It reads nothing from the DOM while the finger is moving: containers are measured when the drag starts, the move handler only records a coordinate, and a rAF loop compares the two. It also listens for `dragover`, because a mouse drag fires no pointer move at all while it is in flight — without that, desktop autoscroll would have silently died. - **`will-change: transform` on `.card-dragging`.** The library rewrites that clone's transform every frame; off its own compositor layer that repaints two soft shadows every frame with it. ## What I verified No container engine or dotnet in this runner, so the compose stack and the Playwright suite could not be started. Instead I built an offline harness that loads the repo's real `app.css`, `sortable.min.js`, `sortable-interop.js` and `board-pagination.js` into a board of 4 columns × 14 cards, and drives real touch drags over CDP against an emulated Pixel 7 (confirmed `nativeDraggable === false`, i.e. the actual mobile code path). **Traced, 10× CPU throttle, median of 7 runs:** | | before | after | |---|---|---| | script | 1442 ms | 979 ms | | style recalc | 292 ms | 180 ms | | paint | 361 ms | 214 ms | | total main-thread | 4086 ms | 2990 ms | | `elementFromPoint` | 238 ms | 90 ms | **Functional checks, all passing:** reorder within a column still fires `OnCardMoved` at the right index; a drop on a pagination tile still moves the card and strands nothing in the bar; a card held at a column's bottom edge scrolls it; a card held at the board's right edge scrolls sideways; the frame loop stops on drop; and a `dragover`-only coordinate drives the loop (the desktop path). ## Tests and docs - `board.spec.js` pins both Sortable options and the `will-change` promotion. - `mobile.spec.js` gains a `Touch drag` group that drives a real CDP touch drag and asserts a held card scrolls its column — the behaviour that moved from the library into this repo. It creates its own card so teardown can reclaim it. **These two are unrun** — they need the compose stack. Every assertion in them was checked true against the real files in the harness, but the live-board wiring around them has not executed. - `CLAUDE.md` records the desktop/touch asymmetry, why `dragScroll` reads nothing per move, and the two non-obvious rules in it. One thing I deliberately did not do: `animation: 0` measured another ~15% off script time, but it costs the reorder slide on every platform, and the wins above don't need it. --- ⏱ 43m 38s · 126 turns · tokens: 210.3k in / 96.2k out (+17328.1k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-64/run-164.md) → Opened PR: https://git.grandiras.net/Grandiras/Ponente/pulls/67
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente#64
No description provided.