fix(dashboard): stop trusting pointercancel end coordinates in drag hook - #9018
Conversation
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 (kirodotdev#8958), whose harvested rule (platform-fired ends can carry sentinel coordinates) names this residual on the same seam.
UX Review (Fable 5, fork) — ✅ PASSUX-level review of The diff touches only UX-Verdict: PASS Internal drag-hook guard fix with no user-visible surface; it only prevents a phantom pane-collapse on platform-cancelled drags. [UX-REVIEWED] ee92171 |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of The patch matches the description end-to-end: the deny-list → allow-list inversion in Design-Verdict: PASS Inverting to an allow-list on the one spec-defined user-driven end fixes the root cause #8958 only symptom-patched; lossless on conformant engines. [DESIGN-REVIEWED] ee92171 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've analyzed the diff against the base code. The change narrows the tracker-refresh allow-list in Verifying the falsification bar myself:
The discovery pass's "no candidates" holds under independent re-derivation, and I found no groundable Step-2 finding: every (a)/(b)/(c) chain I could construct resolves to "lossless or improved," not an observable wrong outcome. No findings. [OPUS-REVIEWED] ee92171 |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All evidence checked. The cancel path in First-Principles-Verdict: PASS A real spec-divergence defect fixed at its cause — the trust classification itself — in the one shared hook, with zero new surface. What this change shipsIntent: stop a platform-fired
Every item is derived: the harm is a persisted wrong layout ( [FIRST-PRINCIPLES-REVIEWED] ee92171 |
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: single clear root cause -- the drag hook refreshed its position tracker on every terminal pointer event, so a platform-fired pointercancel carrying sentinel 0,0 coordinates produced an end payload with dx of roughly -startX that resizers persist to localStorage; the fix narrows the tracker refresh to an allow-list of pointerup only, so the end payload derives from the last user-driven position. Touches one source hook plus its own test file, no behaviour change on spec-conformant engines. 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.
Problem / Motivation
usePointerDragderives itsonEndpayload from a position tracker so that terminal events carrying sentinel coordinates cannot corrupt the reported delta (#8958). The tracker-refresh guard, however, is a deny-list that excludes onlylostpointercapture:pointercancelpasses this guard. A cancel is platform-fired (touch scroll takeover, pen leaving digitizer range, palm rejection, screen-orientation change) — the user never chose its position — and engines have shipped it with default-initialized coordinates (0,0). Pointer Events Level 3 had to add an explicit clarification thatpointercancelcoordinates must match the last dispatched pointer event precisely because behavior diverged. On an engine delivering the 0,0 shape, the guard refreshes the tracker to 0,0 andonEndreportsdx ≈ -startX.The in-code comment claims "pointerup/pointercancel carry real coordinates" — for
pointercancelthat is only true on engines conforming to the L3 clarification, and the hook's own harvested rule from #8958 ("platform-fired ends can carry sentinel coordinates") describespointercancelexactly.Why it matters
Resizer consumers run
persist(apply(sign * dx))inonEnd. A mid-dragpointercancelwith sentinel coordinates commits a clamped-extreme or collapsed pane size and writes it tolocalStorage— a persisted wrong layout from an end the user never performed. This is the same harm class #8958 closed forlostpointercapture, reachable through the one platform-fired end type the deny-list still trusts.What changed (motivation → approach → change)
Observed symptom: at the unfixed tree, a synthetic
pointercancelwithclientX: 0, clientY: 0after mid-drag moves producesonEnd({ dx: -100, dy: -100, x: 0, … })instead of the last tracked position (reproducer output below).Root cause: the coordinate-refresh guard classifies end events by denying known-bad types instead of allowing known-good ones. Any platform-fired end type not on the deny-list is trusted by default —
pointercanceltoday, and any future terminal type tomorrow.Change: 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 excluded 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 (worst case: one coalesced-move stale, never a corrupt delta). The contract comment and the
DragInternaldocstring are synced to the repaired behavior in the same commit.Alternatives rejected:
pointercancel: repeats the structural mistake; a future platform-fired terminal type would be trusted by default again.if (x || y)): 0,0 is a legal on-screen position for a realpointerup; guarding by value corrupts a genuine top-left release.Lineage (one topic per PR): #8904 made the hook terminate on capture loss; #8958 derived end coordinates from the tracker for
lostpointercapture; this PR closes the remaining cancel-class window that #8958's own review round named as a residual. Each change is a distinct guard on the same seam.Tests
Two attack pins, one conformant control, one idempotence pin (all in
website/src/hooks/usePointerDrag.test.tsx):a pointercancel with sentinel 0,0 coordinates ends from the last tracked position— mid-drag cancel at 0,0 must report the moved-to position (fails before, passes after).a pointercancel before any move ends at the drag origin (dx 0), not at 0,0— pre-move cancel must report the origin (fails before, passes after).a spec-conformant pointercancel (coordinates match the last dispatched event) ends identically— proves the allow-list is lossless on conformant engines (passes both sides; regression control, stated honestly).does not double-fire onEnd when lostpointercapture follows a pointercancel— the spec's implicit-release sequence fireslostpointercaptureright afterpointercancel; end stays single-fire (passes both sides).Reproducer output at the unfixed tree (base
0d65dc969):After the fix:
13 passed (13). All nine pre-existing hook tests pass unchanged — includinga normal pointerup still ends from its own (real) coordinates, which pins that the user-driven path still trusts its event.Consumer sweep: 115 test files across every
usePointerDragcall site (ChatInput, SessionGridLayout, ChatSidebar, useColumnResize, ColumnSplitter, BottomTerminalPanel, DetailPanel, SidePanel, FileExplorerPage, ResizeHandle) — all green.tsc -bclean,eslintclean, theme/phantom/i18n lints clean, full website suite green.Manual verification
Verified through the jsdom harness above (synthetic
No user-visible UI change: the fix alters which coordinates an internal tracker trusts on a platform-fired drag end. Layout, styling, and the user-driven drag path are untouched, so there is nothing to screenshot.pointerCancelwith explicit coordinates). No real-device reproduction was performed — provoking a genuine palm-rejection or scroll-takeover cancel deterministically requires hardware; the unit pins encode both engine behaviors (sentinel and conformant) instead.Related Issues
Follow-up to #8958 (capture-loss drag ends) and #8904 (capture-loss termination) — closes the
pointercancelresidual named in #8958's review round. Non-closing reference: no tracker issue exists for this residual.Pattern harvest
The deny-list trusted an event type nobody had audited: the guard was written for the
lostpointercaptureincident and excluded exactly that type, leaving every other platform-fired end trusted by default. The durable shape is to key coordinate trust to the one user-driven end type and exclude platform-fired ends as a class.Rule candidate: when guarding against unreliable event payloads, allow-list the event types whose payload the spec defines as user-driven; a deny-list built from the incident that prompted it silently trusts every type not yet witnessed.
Adjacent same-seam residual, deliberately not in this PR (one topic): the hook swallows
setPointerCapturefailure, so a drag whose capture never engaged can still miss its end if the pointer is released off-element. Registered follow-up; distinct mechanism (capture acquisition, not end-coordinate trust).Checklist
Contribution License Agreement
Per the template placeholder (CLA text pending): offered under the same terms as my prior merged contributions to this repository (#8835).