Skip to content

Give every capture-owning gesture the same capture-loss policy - #348

Merged
masenf merged 2 commits into
mainfrom
fix/pan-state-sticks-on-capture-loss
Jul 27, 2026
Merged

Give every capture-owning gesture the same capture-loss policy#348
masenf merged 2 commits into
mainfrom
fix/pan-state-sticks-on-capture-loss

Conversation

@FarhanAliRaza

@FarhanAliRaza FarhanAliRaza commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #212.

The bug

Pan a chart, move the cursor out of the chart area, release the button outside
it — on returning, the chart keeps panning as though the button were still
held.

Pointer capture is scoped to a single browsing context. When a drag leaves an
embedded iframe and the primary mouse button is released in the parent
document, the iframe never receives pointerup; Chrome reports the lost
capture only once the pointer re-enters. The gesture stayed live across that
gap, so the next pointermove resumed it.

The plot canvas is where this is reported, but capture is acquired in five
places, and the axis-band drag, the lasso vertex handles and the modebar drag
all had identical exposure. Fixing only the pan would have left the same bug
reachable three other ways.

The fix

Capture is now acquired in exactly one place.
ChartView._captureGesturePointer owns the detector — lostpointercapture,
plus a buttonless mouse move as the backstop for browsers that delay or omit it
— and each gesture declares its own end policy:

Gesture On capture loss
Canvas pan Finalizes at its last in-frame view, emits its one end event with interaction_id intact, so history still coalesces the gesture into a single entry
Canvas select / box-zoom Rolls back to the previous selection — the release coordinate that would complete it never arrived
Lasso vertex handle Restores the prior vertex
Axis-band drag Pan finalizes; a span-zoom rolls back
Modebar drag Ends at its last in-frame position

Re-entering the chart never resumes a gesture.

Listener lifecycle

The helper registers through the existing _listen registry rather than a
private addEventListener, because two subsystems depend on that registry
being complete: destroy() sweeps it, and WebGL context-loss recovery walks it
to re-bind canvas handlers onto the replacement canvas. _unlisten detaches
via the record's live target, so a handler the canvas swap moved still
detaches from the node it ended up on.

Escape also now releases before dropping its gesture records. Previously it
nulled them while the capture object was the only handle on its listener,
stranding a listener that stayed armed and re-fired on later gestures.

Scope limit, recorded

The buttonless-move backstop is mouse-only — it reads the primary-button
bit, which has no pen/touch equivalent. Pen and touch rely on
lostpointercapture alone. Recorded in
spec/design/pan-and-zoom-configuration.md rather than left implicit.

Coverage

test_capture_loss_policy_covers_every_capture_owning_gesture drives all five
gestures through a capture loss in headless Chromium and asserts each one's end
policy.

The source-level guard in test_static_client_security.py asserts the durable
invariant — exactly one setPointerCapture site, so capture-loss handling
cannot be opted out of. Which gestures inherit the policy is asserted
behaviorally by the probe rather than by grepping for a call count.

Specs

Updated per the repo's spec-as-source-of-truth rule:
spec/api/interaction.md (observable contract), and the mechanism plus its
mouse-only scope in spec/design/pan-and-zoom-configuration.md and
spec/design/view-state.md.

Verification

  • node js/build.mjs — typechecks and rebuilds
  • uv run pytest — 3187 passed, 1 skipped
  • scripts/abi_smoke.py, scripts/render_smoke_nonumpy.py — pass (the render
    smoke exercises context-loss canvas swaps and destroy(), the paths
    _unlisten touches)
  • pre-commit run --all-files, ruff check ., ruff format --check . — clean

Note for reviewers

Two things I deliberately did not do, both worth their own change:

  • A fuller _beginGesture(owner, event, {onMove, onEnd(reason)}) that owns
    pointerup/pointercancel too. Today the helper centralizes the detector;
    each gesture still hand-writes its onLost and its own release() in the
    normal end path, so a sixth gesture could still forget one. That would be a
    redesign of all five gestures.
  • Splitting the ~400-line _initInteraction closure into per-gesture
    binders.

Separately: tests/reflex_adapter/test_assets.py::test_client_source_is_the_installed_bundle
fails locally because it expects unminified markers in static/index.js while
js/build.mjs emits a minified bundle. It is pre-existing — it fails
identically with this branch's changes stashed — and untouched here.

Pointer capture is scoped to one browsing context. When a canvas drag
leaves an embedded iframe and the primary mouse button is released in the
parent document, the iframe never receives `pointerup` — Chrome reports
the lost capture only once the pointer re-enters. The gesture stayed live
across that gap, so the next move continued panning as though the button
were still held.

Finalize the gesture on `lostpointercapture`: a pan ends at its last
in-frame view and emits its single `end` event, while an unfinished
selection or box-zoom rolls back, having no release coordinate to
complete with. A buttonless mouse move back over the canvas is the
backstop for browsers that omit the lost-capture notification.

Covered by a headless probe asserting the re-entry move applies no
further pan delta and that exactly one `end` event ships with its
interaction id intact.

Axis-band drags take capture the same way and do not carry this backstop
yet; recorded in the pan-and-zoom spec.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Canvas gestures now use shared pointer-capture handling. Lost releases finalize pans or roll back coordinate-dependent gestures, including iframe boundary cases. Specifications and browser tests define and verify the resulting behavior.

Changes

Pointer capture gesture handling

Layer / File(s) Summary
Centralized capture ownership
js/src/50_chartview.ts
Adds shared capture acquisition, loss detection, guarded movement, safe release, and listener cleanup.
Unified gesture termination
js/src/53_interaction.ts, js/src/57_viewstate.ts
Routes lasso, pan, selection, box, axis-band, and modebar gestures through shared capture guards and completion or rollback paths.
Iframe boundary behavior contract
spec/api/interaction.md, spec/design/pan-and-zoom-configuration.md, spec/design/view-state.md
Documents pan finalization, rollback of incomplete gestures, and prevention of re-entry resumption after capture loss.
Capture-loss validation
tests/test_view_state_client.py, tests/test_static_client_security.py
Adds browser coverage for all capture-owning gestures and enforces centralized capture acquisition.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Canvas
  participant ChartView
  participant InteractionHandlers
  participant ViewState
  Canvas->>ChartView: pointerdown
  ChartView->>InteractionHandlers: capture guard and release handles
  Canvas->>ChartView: lostpointercapture or buttonless re-entry
  ChartView->>InteractionHandlers: invoke gesture loss handler
  InteractionHandlers->>ViewState: finalize pan or restore committed state
Loading

Possibly related PRs

  • reflex-dev/xy#238: Modifies related selection and box-zoom cancellation and overlay restoration logic.

Suggested reviewers: alek99, carlosabadia

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The direct issue is addressed by ending pan on lostpointercapture and preventing re-entry from continuing the drag.
Out of Scope Changes check ✅ Passed The added gesture, spec, and test updates stay within the broader capture-loss policy described in the PR objectives.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: unifying capture-loss handling across all capture-owning gestures.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pan-state-sticks-on-capture-loss

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/test_view_state_client.py (1)

148-185: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Exercise the actual fallback and assert interaction identity.

Script-created PointerEvents are untrusted, so this probe cannot cover the Line 283 buttonless-re-entry guard when lostpointercapture is omitted. Also, interaction_id != null does not prove the end event retained the pan’s ID. Record the update ID, require equality, assert the exact result shape, and add a trusted-input probe without lostpointercapture.

Proposed assertion strengthening
     const endEvents = [];
+    const updateEvents = [];
     view.root.addEventListener("xy:view_change", (event) => {
-      if (event.detail.source === "pan_drag" && event.detail.phase === "end") {
+      if (event.detail.source !== "pan_drag") return;
+      if (event.detail.phase === "end") {
         endEvents.push(event.detail);
+      } else if (event.detail.phase === "update") {
+        updateEvents.push(event.detail);
       }
     });
@@
-      finalEndKeptInteraction: endEvents[0]?.interaction_id != null
+      finalEndKeptInteraction:
+        endEvents[0]?.interaction_id ===
+          updateEvents[updateEvents.length - 1]?.interaction_id
         && endEvents[0]?.axes?.length > 0,
@@
-    assert result == {key: True for key in result}
+    assert result == {
+        "reentryDidNotPan": True,
+        "finalEndEmittedOnce": True,
+        "finalEndKeptInteraction": True,
+    }

Also applies to: 193-200

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_view_state_client.py` around lines 148 - 185, Strengthen the
view-state probe around the pointer helper and final assertions: record the pan
interaction ID from the update event, require the end event’s interaction_id to
equal it, and assert the exact expected result shape rather than only checking
non-null values. Add a separate trusted-input probe that omits
lostpointercapture to exercise the buttonless re-entry fallback guarded near the
existing pointer handling logic, while preserving the current lost-capture
scenario.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_view_state_client.py`:
- Around line 148-185: Strengthen the view-state probe around the pointer helper
and final assertions: record the pan interaction ID from the update event,
require the end event’s interaction_id to equal it, and assert the exact
expected result shape rather than only checking non-null values. Add a separate
trusted-input probe that omits lostpointercapture to exercise the buttonless
re-entry fallback guarded near the existing pointer handling logic, while
preserving the current lost-capture scenario.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ab256b8e-8531-4674-8411-b38ae56ed9af

📥 Commits

Reviewing files that changed from the base of the PR and between 63d52e4 and 4601330.

📒 Files selected for processing (5)
  • js/src/53_interaction.ts
  • spec/api/interaction.md
  • spec/design/pan-and-zoom-configuration.md
  • spec/design/view-state.md
  • tests/test_view_state_client.py

@codspeed-hq

codspeed-hq Bot commented Jul 27, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 103 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing fix/pan-state-sticks-on-capture-loss (54268a2) with main (2ffd521)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@masenf

masenf commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

The deeper shape is one capture-loss policy every
capture-owning gesture inherits; that is a larger change than this fix.

It seems this is the fix we want anyway though. Seems weird to half-fix the issue just for panning.

The previous commit fixed the stuck pan only for the plot canvas, but
pointer capture is acquired in five places, and the axis-band drag, the
lasso vertex handles and the modebar drag all had identical exposure: a
button released outside an embedding iframe never delivers pointerup, so
the gesture stayed live and resumed on re-entry.

Acquire capture in exactly one place. `ChartView._captureGesturePointer`
owns the detector — `lostpointercapture`, plus a buttonless mouse move as
the backstop for browsers that delay or omit it — and each gesture
declares its own end policy: a pan finalizes at its last in-frame view,
gestures needing a release coordinate roll back to their last committed
state, and chrome drags simply release.

The helper registers through `_listen`, so its listener is visible to
both consumers of that registry: `destroy()` sweeps it, and context-loss
recovery re-binds it onto the replacement canvas. `_unlisten` detaches
via the record's live target, so a handler the canvas swap moved still
detaches from the node it ended up on. Escape now releases before
dropping its gesture records, which previously stranded a listener that
kept firing on later gestures.

The backstop is mouse-only, since it reads the primary-button bit; pen
and touch rely on `lostpointercapture` alone. That scope is recorded in
the pan-and-zoom spec rather than left implicit, and the API spec now
states the observable contract instead of the call-site count.

Covered by a headless probe driving all five gestures through capture
loss. The source-level guard asserts the single acquisition site; which
gestures inherit the policy is asserted behaviorally instead of by
grepping for a call count.
@FarhanAliRaza FarhanAliRaza changed the title Release a stuck pan when pointer capture is lost Give every capture-owning gesture the same capture-loss policy Jul 27, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/src/53_interaction.ts (1)

380-383: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pan gestures ended via pointercancel or Escape never finalize, unlike every other termination path. Both sites bypass endGestureWithoutRelease — the helper that implements this PR's own policy of "pan finalizes, selection/box-zoom rolls back" — and instead run unconditional "abandon" cleanup, so an in-progress pan's final position is never sent to the kernel via a pan_drag "end" view-change. Since update-phase events are gated on wantsViewChange(), the kernel's view_state() cache is left stale after either path. js/src/57_viewstate.ts's axis-band end() shows the correct pattern: unify pointerup/pointercancel/capture-loss through one function that always finalizes a pan regardless of how it ended.

  • js/src/53_interaction.ts#L380-L383: change this._listen(c, "pointercancel", cancelPointerGesture); to this._listen(c, "pointercancel", endGestureWithoutRelease);.
  • js/src/53_interaction.ts#L423-L437: replace the inline band?.capture.release(); drag?.capture.release(); ...; band = null; drag = null; block with a call to endGestureWithoutRelease();.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/src/53_interaction.ts` around lines 380 - 383, Pan gestures terminated by
pointercancel or Escape bypass endGestureWithoutRelease, leaving the kernel view
state stale. In js/src/53_interaction.ts lines 380-383, route pointercancel to
endGestureWithoutRelease instead of cancelPointerGesture; in lines 423-437,
replace the inline capture-release and state-reset cleanup with
endGestureWithoutRelease so pan gestures finalize while selection and box-zoom
retain the established rollback behavior.
🧹 Nitpick comments (1)
tests/test_view_state_client.py (1)

156-229: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

This probe still misses the trusted buttonless-reentry fallback. The dispatchEvent()-driven pointer events here are untrusted, so the moveEvent.isTrusted branch in _captureGesturePointer never runs. Add a trusted-input path (for example via CDP/Playwright) or a separate test for that fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_view_state_client.py` around lines 156 - 229, The pointer-event
probe only exercises untrusted dispatchEvent events and therefore does not cover
the moveEvent.isTrusted fallback in _captureGesturePointer. Add a separate
trusted-input test using CDP/Playwright, or otherwise invoke a browser-generated
trusted buttonless reentry, and verify that the active gesture does not continue
panning after capture loss.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@js/src/53_interaction.ts`:
- Around line 380-383: Pan gestures terminated by pointercancel or Escape bypass
endGestureWithoutRelease, leaving the kernel view state stale. In
js/src/53_interaction.ts lines 380-383, route pointercancel to
endGestureWithoutRelease instead of cancelPointerGesture; in lines 423-437,
replace the inline capture-release and state-reset cleanup with
endGestureWithoutRelease so pan gestures finalize while selection and box-zoom
retain the established rollback behavior.

---

Nitpick comments:
In `@tests/test_view_state_client.py`:
- Around line 156-229: The pointer-event probe only exercises untrusted
dispatchEvent events and therefore does not cover the moveEvent.isTrusted
fallback in _captureGesturePointer. Add a separate trusted-input test using
CDP/Playwright, or otherwise invoke a browser-generated trusted buttonless
reentry, and verify that the active gesture does not continue panning after
capture loss.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: afe5fb77-a839-4b01-8172-d659c16d3c3d

📥 Commits

Reviewing files that changed from the base of the PR and between 4601330 and 54268a2.

📒 Files selected for processing (8)
  • js/src/50_chartview.ts
  • js/src/53_interaction.ts
  • js/src/57_viewstate.ts
  • spec/api/interaction.md
  • spec/design/pan-and-zoom-configuration.md
  • spec/design/view-state.md
  • tests/test_static_client_security.py
  • tests/test_view_state_client.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • spec/design/view-state.md
  • spec/api/interaction.md

@masenf
masenf merged commit 55b0ca8 into main Jul 27, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

when the cursor leaves the chart area, sometimes the pan state sticks

2 participants