Skip to content

fix(dashboard): derive capture-loss drag ends from the last tracked position - #8958

Merged
iamwhatever merged 1 commit into
kirodotdev:mainfrom
javenciu:fix/pointer-drag-capture-loss-coords
Sep 6, 2026
Merged

fix(dashboard): derive capture-loss drag ends from the last tracked position#8958
iamwhatever merged 1 commit into
kirodotdev:mainfrom
javenciu:fix/pointer-drag-capture-loss-coords

Conversation

@javenciu

@javenciu javenciu commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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: lostpointercapture can arrive with clientX/clientY of 0,0 in 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 (dx on 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 / pointercancel behave byte-identically to today (the refresh makes tracker-derived and event-derived payloads equal by construction).
  • lostpointercapture commits the last position actually observed (pointerdown or the latest pointermove), never the event's 0,0.
  • Pre-threshold moves are tracked too, so a capture loss during hysteresis ends at the true small delta rather than a fabricated one.

The single-fire re-entry guard (active = false before releasePointerCapture) is untouched.

Tests

  • Attack: capture loss at 0,0 after down(100,100) commits x:100 y:100 dx:0 dy:0 (fails on main as merged with dx:-100, x:0, y:0, passes with this fix).
  • Fresher-event: down(100,100)move(150,110) → capture loss at 0,0 commits 150,110 / dx:50 / dy:10.
  • Pre-move capture loss commits at origin with dx:0, committed:false.
  • Control: the normal pointerup path commits event coordinates unchanged.
  • Full src/hooks/ suite: 22 files, 260 passed at this head. tsc --noEmit exit 0, eslint exit 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

  • Tests added for the change (attack, fresher-event, pre-move, control)
  • All tests pass locally (src/hooks/: 260 passed)
  • Lint and typecheck clean (eslint, tsc --noEmit both exit 0)
  • One topic, minimal diff (2 files: hook + its test)

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).

…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.
@javenciu
javenciu requested a review from a team September 6, 2026 08:50
@javenciu
javenciu requested a review from a team as a code owner September 6, 2026 08:50
@javenciu
javenciu requested a review from CrysisDeu September 6, 2026 08:50
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5, fork) — ✅ PASS

UX-level review of 7f1de495cec281e442352b5ddaaed993c70c0da4 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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 DragInternal tracker plus one e.type !== 'lostpointercapture' guard; the normal pointerup path is unchanged by construction and pinned by a control test).

[UX-REVIEWED] 7f1de49

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review (fork) — ✅ no blocking findings

Reviewed 7f1de495cec281e442352b5ddaaed993c70c0da4 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 7f1de49

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5, fork) — ✅ PASS

Design-level review of 7f1de495cec281e442352b5ddaaed993c70c0da4 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • The PR's own harvested rule ("platform-fired ends can carry sentinel coordinates") also describes pointercancel, yet the deny-list e.type !== 'lostpointercapture' trusts it; inverting to an allow-list that refreshes the tracker only on pointerup would make cancel-class ends commit last-observed positions too, closing the same hole if any engine delivers a 0,0 cancel.

[DESIGN-REVIEWED] 7f1de49

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review (fork) — ✅ no blocking findings

Reviewed 7f1de495cec281e442352b5ddaaed993c70c0da4 via the fork AI-review pipeline; updated in place on each push.

Review details

The discovery pass produced no candidates. I independently traced the changed logic:

  • onPointerDown re-initializes lastX/lastY, so no stale carry across drags.
  • onPointerMove updates the tracker before the threshold check, so a mid-hysteresis capture loss ends at the true small delta.
  • In end, s.active=false precedes releasePointerCapture, so the release-triggered lostpointercapture is a no-op re-entry (no double onEnd).
  • The e.type !== 'lostpointercapture' guard refreshes the tracker for pointerup/pointercancel (spec-defined coords) and derives the payload from the tracked position on capture loss.

No concrete input, call path, and observable wrong outcome trio reaches the 80+ bar. Nothing survives.

No findings.

[OPUS-REVIEWED] 7f1de49

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5, fork) — ✅ PASS

Premise-level review of 7f1de495cec281e442352b5ddaaed993c70c0da4 via the fork AI-review pipeline — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

The evidence is complete. The hook is the only lostpointercapture handler in the tree, useColumnResize.ts:193 is the counted persisting consumer, and the diff adds no public surface. Emitting the review.

First-Principles-Verdict: PASS

Fixes a real corrupted-commit path at the shared mechanism, adds zero public surface, and one counted consumer (useColumnResize.ts:193) proves the harm.

What this change ships

Intent: stop a mid-drag capture loss from persisting a garbage pane/column size derived from the event's undefined 0,0 coordinates. This is a FIX (follow-up hardening to #8904).

  1. A capture-loss drag now ends at the last real pointer position, not 0,0 — justified
  2. Moves before the drag threshold are now tracked, so a capture loss mid-hysteresis ends at the true small delta — justified, declared
  3. Normal pointerup/pointercancel ends are unchanged (refresh-then-derive is equal by construction) — justified, control-tested
  4. Two private tracker fields (lastX/lastY) — internal state, no new public surface
  5. Three new tests (attack, control, pre-move) — declared

Verification behind the verdict: the harm is derived, not inherited — the Pointer Events spec leaves lostpointercapture coordinates undefined, and exactly one consumer reads payload coordinates in onEnd and persists them (grep onEnd: across website/src, 14 non-test sites: only useColumnResize.ts:190 destructures dx; the rest persist from refs or only tear down). Sibling count: lostpointercapture is handled in exactly one place (usePointerDrag.ts:121), so fixing the shared hook heals all 10 consumer components at once — cause level, zero unfixed siblings. PointerDragState and PointerDragOptions are untouched, so nothing here is one-way-door surface.

[FIRST-PRINCIPLES-REVIEWED] 7f1de49

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 6, 2026
@iamwhatever
iamwhatever enabled auto-merge (squash) September 6, 2026 10:04

@iamwhatever iamwhatever left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@iamwhatever
iamwhatever merged commit 0d65dc9 into kirodotdev:main Sep 6, 2026
67 checks passed
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 6, 2026
iamwhatever pushed a commit that referenced this pull request Sep 6, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants