fix(dashboard): derive capture-loss drag ends from the last tracked position - #8958
Conversation
…osition The Pointer Events spec leaves pointer coordinates undefined on lostpointercapture and browsers commonly deliver 0,0, so building the onEnd payload from the event's clientX/clientY hands consumers dx of roughly -startX on a mid-drag capture loss. Resizer consumers run persist(apply(sign * dx)) in onEnd, which would snap a column to a clamped extreme or its collapsed state and write it to storage. DragInternal now tracks the last coordinate-bearing position (down/move/up/cancel refresh it; capture-loss events do not) and every end path builds its payload from the tracked position, keeping the payload contract browser-independent. Surfaced by the design review on kirodotdev#8904.
UX Review (Fable 5, fork) — ✅ PASSUX-level review of UX-Verdict: PASS No user-visible surface changes: the diff fixes which coordinates a drag-end reads, adding no control, string, state, or rendered pixel — nothing for a first-time user to see. The only experiential effect is corrective: a mid-drag capture loss now commits the pane/column width the user last saw instead of silently persisting a collapsed layout to storage. No evidence gaps apply — the diff adds or changes no user-visible control, and the PR's "no visual delta" claim matches the code (a [UX-REVIEWED] 7f1de49 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix at the right layer: the shared hook stops trusting spec-undefined capture-loss coordinates, so every resizer consumer is healed at once. Suggestions
[DESIGN-REVIEWED] 7f1de49 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe discovery pass produced no candidates. I independently traced the changed logic:
No concrete input, call path, and observable wrong outcome trio reaches the 80+ bar. Nothing survives. No findings. [OPUS-REVIEWED] 7f1de49 |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of The evidence is complete. The hook is the only First-Principles-Verdict: PASS Fixes a real corrupted-commit path at the shared mechanism, adds zero public surface, and one counted consumer ( What this change shipsIntent: stop a mid-drag capture loss from persisting a garbage pane/column size derived from the event's undefined
Verification behind the verdict: the harm is derived, not inherited — the Pointer Events spec leaves [FIRST-PRINCIPLES-REVIEWED] 7f1de49 |
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: pointer-drag hook ended a capture-loss drag from lostpointercapture's undefined 0,0 coordinates instead of the last tracked position, so onEnd consumers persisted a collapsed layout; fix reuses the tracked position on that path only, leaving spec-defined pointerup/cancel coordinates authoritative. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
…ook (#9018) usePointerDrag's end payload derives from a position tracker so that terminal events with sentinel coordinates cannot corrupt the reported delta. The refresh guard was a deny-list that excluded only lostpointercapture: a pointercancel delivered with default-initialized coordinates (0,0 -- shipped engine behavior that Pointer Events Level 3 later had to clarify against) refreshed the tracker and handed consumers an end payload of dx of roughly -startX. Resizer consumers run persist(apply(sign * dx)) in onEnd, so a platform-fired cancel (touch scroll takeover, pen leaving digitizer range, palm rejection) mid-drag committed a clamped or collapsed pane size to localStorage. Invert the guard to an allow-list: only pointerup, the user-driven end whose coordinates are spec-defined, refreshes the tracker; every platform-fired end commits the last tracked position. On a conformant engine the cancel carries the last dispatched coordinates, exactly what the tracker already holds, so the inversion is lossless there and fail-safe on engines that deliver sentinels. Follow-up to the capture-loss end derivation (#8958), whose harvested rule (platform-fired ends can carry sentinel coordinates) names this residual on the same seam.
Problem / Motivation
#8904 (merged) ends pointer drags when capture is lost, so panes no longer stick to the cursor after a
lostpointercapture. The Design Review thread on that PR flagged a residual:lostpointercapturecan arrive withclientX/clientYof0,0in several engines, and the merged handler derives the commit payload from the event's coordinates. A capture loss mid-drag can therefore commit a corrupted delta (dxon the order of-startX) into consumers that persist their result, such as column layouts that write widths to storage.Why it matters
A user who drags a column and hits a capture loss (context menu, alt-tab, browser-initiated grab) can have a garbage width persisted, and it stays wrong across reloads until they re-drag. The corruption is silent: the drag just ends, and the bad value looks like a deliberate resize.
What changed (motivation → approach → change)
The commit payload now always derives from the last tracked pointer position instead of the raw event coordinates. The tracker is refreshed from the event's coordinates immediately before payload derivation on every end path except
lostpointercapture, so:pointerup/pointercancelbehave byte-identically to today (the refresh makes tracker-derived and event-derived payloads equal by construction).lostpointercapturecommits the last position actually observed (pointerdownor the latestpointermove), never the event's0,0.The single-fire re-entry guard (
active = falsebeforereleasePointerCapture) is untouched.Tests
0,0afterdown(100,100)commitsx:100 y:100 dx:0 dy:0(fails on main as merged withdx:-100, x:0, y:0, passes with this fix).down(100,100)→move(150,110)→ capture loss at0,0commits150,110 / dx:50 / dy:10.dx:0,committed:false.pointeruppath commits event coordinates unchanged.src/hooks/suite: 22 files, 260 passed at this head.tsc --noEmitexit 0,eslintexit 0 on both changed files.Manual verification
Drag a column, trigger a capture loss (open a context menu mid-drag), release: the column keeps the width from the last real pointer position instead of collapsing toward zero, and the persisted value matches what was on screen.
Screenshots / video
No visual delta: this changes which coordinates a drag-end computation reads, not any rendered output. Behavior is pinned by the attack/control tests above.
Related Issues
Follow-up to #8904 (fix for #8271); implements the hardening surfaced by the Design Review thread on that PR.
Pattern harvest
Event-derived commit payloads are only as trustworthy as the event: end-of-interaction events fired by the platform rather than the user (
lostpointercapture, and by analogy blur-driven cancels) can carry sentinel coordinates, so any handler that persists a delta should derive it from tracked state and treat platform-fired ends as "commit what you last saw".Rule candidate: when a handler family commits state on interaction end, review whether every end path derives its payload from user-observed positions rather than the terminating event's own coordinates.
Checklist
src/hooks/: 260 passed)eslint,tsc --noEmitboth exit 0)Contribution License Agreement
Per the template placeholder (CLA text pending): offered under the same terms as my prior merged contributions to this repository (#8835, #8904).