Skip to content

Commit 2b36f43

Browse files
authored
Merge pull request #534 from sjefferson99/525-2-2-drag-hot-path
Fix board drag hot path (issue #525 item 2.2)
2 parents d4bd401 + 40dffb5 commit 2b36f43

3 files changed

Lines changed: 246 additions & 51 deletions

File tree

AGENT_CONTEXT.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,12 @@ Security workflow
4343
- If issues are introduced, fix and rescan until no new issues are reported for changed paths.
4444

4545
PR review workflow (agent-assisted)
46-
- Review sequentially (one pass, one train of thought) rather than fanning out into many parallel/competing subagents — parallel review agents on this repo have produced confusing, overlapping, hard-to-follow output.
46+
- **MANDATORY size check before `/code-review` or any multi-agent review flow**: run `git diff --stat` first. A single file or under ~300 changed lines gets ONE sequential pass — never parallel multi-agent fanout. This has already been violated once (a ~200-line single-file diff triggered a 7-subagent fanout producing ~30 overlapping findings; the user had to interrupt it as far too much effort for the size of the change). Parallel fanout is reserved for genuinely large/multi-file/high-risk diffs, and even then default to sequential unless the user asks for more.
4747
- Reproduce findings before reporting them: don't just read the diff, actually exercise the changed code (e.g. run the validator against a crafted input) and compare behaviour against the pre-change version to confirm a regression is real, not theoretical.
4848
- Post findings as PR comments (a summary comment plus inline comments anchored to the relevant lines) and stop — wait for explicit go-ahead before committing any fix, even when the fix seems obvious.
49-
- Any PR comment posted by an agent on a human's GitHub account must include a disclaimer that the text is LLM/agent-generated, since the comment otherwise appears to come directly from the account owner.
49+
- **MANDATORY before every `gh pr comment` / `gh api .../comments` call**: the comment `body`'s FIRST LINE must be `> **⚠️ AI-generated comment, posted on behalf of @<username> — not written by them personally.**`, followed by a blank line, then the content. A trailing "🤖 Generated with Claude Code" footer at the bottom is NOT sufficient — this has already been tried and judged insufficient once (two PR #534 comments posted with only a bottom footer; the account owner had to point out the comments read as if written by them personally). Before sending, re-read the actual `body` string and confirm the disclaimer is literally line 1 — this is a mechanical check to perform every time, not a judgment call to skip when a comment feels short/routine. If a comment was already posted without it, edit it in place immediately (`gh api repos/{owner}/{repo}/issues/comments/{id} -X PATCH --input <jsonfile>` — plain `-f body=@path` silently fails on Windows/Git-Bash and posts the literal string; build the JSON with Python's `json.dump` and pass via `--input`, then verify with a follow-up GET).
5050
- After a fix is approved and applied, re-verify with the same reproduction used to find the bug, then let the human decide about resolving/closing review threads.
51+
- **MANDATORY relevance check before "just to be safe" test re-runs**: after applying a fix, name which files actually changed before running any test suite. A JS-only change does not need a full backend pytest re-run "for extra confidence" — this has already been violated once (an unprompted full backend suite re-run for a frontend-only fix spiralled into two colliding concurrent runs against the same dev DB, producing spurious failures, plus 20+ minutes of silently polling a stalled process before the user had to ask what was happening). Run only the narrowest subset that exercises the changed code path. Never start a second run of the same suite while one is still in flight against the same shared dev DB. If a background process runs past ~1.5x its own typical runtime with no output, check logs for evidence of real progress or surface the delay to the user — don't keep silently rescheduling wakeups.
5152

5253
Useful commands
5354
- Rebuild dev stack: docker compose down; docker compose up -d --build

docs/PERFORMANCE_board_updates.md

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ checklist items. Follow it.
413413
This is a prerequisite for item 2.3: incremental patching is only cheap if inserting a card
414414
node does not require re-wiring listeners.
415415

416-
### 2.2 Fix the drag hot path — **1 day**
416+
### 2.2 Fix the drag hot path — **1 day** — ✅ implemented and measured
417417

418418
**Measured, not just predicted** — see
419419
[Drag measurement](#drag-measurement-manual-chrome-devtools) above: one realistic drag (down
@@ -451,6 +451,76 @@ Fix:
451451
Re-measure with the same manual procedure after this fix lands — the 12.57s number is the
452452
"before" to beat.
453453

454+
**Implemented** (`getDragAfterElement()`, `setupDragAndDrop()`, and the mobile touch-drag
455+
equivalent `moveMobileTouchCardDrag()` in `www/js/board.js`), on branch `525-2-2-drag-hot-path`:
456+
- `getDragAfterElement()` now binary-searches a per-container cache of sorted card
457+
midpoints (`buildDragMidpointCache()`/`dragMidpointCaches`) instead of calling
458+
`getBoundingClientRect()` on every card on every event.
459+
- The cache is invalidated when the DOM changes for that container: a card is moved during
460+
drag (via the shared `placeDraggedCard()` helper, used by both the native and mobile touch
461+
paths), a card is removed by another client mid-drag (`handleCardDeleted`/
462+
`handleCardArchived`), or `renderBoard()` rebuilds the DOM wholesale (covers any other
463+
reload path, e.g. a websocket-triggered reload mid-drag). It's cleared entirely on
464+
`dragstart`/drag end. Auto-scroll doesn't invalidate at all — `shiftDragMidpointCache()`
465+
adjusts every cached midpoint by the scroll delta in place, so sustained auto-scroll (60
466+
ticks/sec) doesn't force a full re-measure of the column every frame.
467+
- Both the native `dragover` handler and the mobile `touchmove` handler (which shares the same
468+
`getDragAfterElement()` hot path, and now the same `placeDraggedCard()` placement logic) are
469+
throttled to one placement update per `requestAnimationFrame`, coalescing bursts of pointer
470+
events into at most 60 DOM-mutating updates/sec regardless of how many raw events fire.
471+
- `drop` (native) and `finishMobileTouchCardDrag()` (mobile) are one-off, not hot-path calls,
472+
so they force a fresh cache build before reading, and invalidate again after mutating,
473+
rather than trusting a possibly-stale cached one.
474+
- Correctness verified with a Playwright script exercising `getDragAfterElement()` directly
475+
against the real 800-card seeded board (`perf-tests/seed_board.py`): 0 mismatches across 133
476+
sampled pointer y-positions (including every card's exact midpoint — the boundary case a
477+
code review caught a genuine off-by-one regression in, since fixed: the binary search now
478+
matches the original algorithm's strict `y < midpoint` semantics) compared against the
479+
original brute-force algorithm, plus targeted checks for cross-column cache isolation,
480+
invalidation, scroll-shift, and `renderBoard()` cache-clearing. Native HTML5 drag itself
481+
could not be simulated headlessly to validate the full interaction end-to-end — same
482+
limitation noted above; this needs the manual `perf-tests/DRAG_MEASUREMENT.md` procedure.
483+
- `pytest` (370 backend tests) and the board/move-card `ui-tests` (13 tests) pass unchanged;
484+
Snyk Code scan on the modified file reports 0 issues.
485+
- A code review of the initial version of this fix found several real gaps, all since fixed:
486+
an off-by-one in the binary search at exact midpoint matches; cache invalidation not covering
487+
non-drag-driven DOM mutation (concurrent card delete/archive from another client, or a
488+
websocket-triggered `renderBoard()` mid-drag); auto-scroll forcing a full cache rebuild every
489+
tick instead of an O(1) shift; the `drop` handler not re-invalidating after its own mutation;
490+
and duplicated placement logic between the native and touch paths (now unified in
491+
`placeDraggedCard()`).
492+
493+
**Re-measured** with the manual `perf-tests/DRAG_MEASUREMENT.md` procedure (Incognito, LastPass/
494+
Bitwarden confirmed absent from the trace's 1st/3rd-party table this run — the first attempt was
495+
re-done after those extensions showed up dominating an initial capture), same drag gesture
496+
(down past ~15-20 cards) on the same 800-card seeded board:
497+
498+
| Metric | Before (baseline) | After (this fix) | Change |
499+
|---|---|---|---|
500+
| Total | 12.57s | **8.95s** | 29% faster |
501+
| Hit test | 1,774.8ms (34.6%) | 1,269.7ms (37.8%) | 28% faster |
502+
| Layout | 525.3ms (10.2%) | 133.5ms (4.0%) | **75% faster** |
503+
| Paint | 480.6ms (9.4%) | 269.2ms (8.0%) | 44% faster |
504+
| Recalculate style | 146.7ms (2.9%) | 54.4ms (1.6%) | 63% faster |
505+
506+
`getDragAfterElement` no longer appears in the Bottom-Up trace at all (previously visible
507+
enough to matter) — the per-event `getBoundingClientRect()` sweep it used to do is gone.
508+
509+
Layout/Recalculate-style/Paint — the costs this fix most directly targets — improved the most
510+
(44-75% faster). **Hit test only improved ~28%** and is now the largest single share of the
511+
trace (37.8%, up in proportion though down in absolute terms): it's Chrome's own cost of
512+
resolving which draggable element the pointer is over on each `dragover`, driven as much by
513+
the drag gesture's real duration/frequency as by how often this code mutates the DOM — rAF
514+
throttling helps but was never going to eliminate it. Total time dropping less than
515+
Layout/Recalc's improvement alone would suggest is a direct consequence of Hit test's
516+
dominant, only-partially-addressable share of the budget.
517+
518+
Caveat: both the before and after numbers are single manual captures (not averaged/repeated
519+
runs), consistent with this document's standing caveat that these are relative, same-host
520+
comparisons rather than precise benchmarks — treat the ~29% total-time improvement as
521+
directionally solid, the Layout/Recalc/Paint drops (much larger than plausible run-to-run
522+
noise) as the more reliable numbers.
523+
454524
`getDropOrderValue()` ([board.js:4434](../www/js/board.js#L4434)) does a similar full
455525
`querySelectorAll` + `indexOf` walk, but only on drop, so it matters far less.
456526

0 commit comments

Comments
 (0)