Skip to content

Fix number slider value getting stuck tied to mouse position (#4231)#4247

Closed
StefanoD wants to merge 1 commit into
GraphiteEditor:masterfrom
StefanoD:fix-slider-value-tied-to-mouse-4231
Closed

Fix number slider value getting stuck tied to mouse position (#4231)#4247
StefanoD wants to merge 1 commit into
GraphiteEditor:masterfrom
StefanoD:fix-slider-value-tied-to-mouse-4231

Conversation

@StefanoD

Copy link
Copy Markdown

Generated by Claude. Review this carefully!

Fixes #4231

Problem

In Increment mode, dragging a number field uses the asynchronous Pointer Lock API.
requestPointerLock() only takes effect once the "pointerlockchange" event fires, and the
drag's cleanup (removing the window pointermove/pointerup listeners) was driven exclusively
by that event via exitPointerLock().

When the interaction ends before the lock engages — e.g. a rapid click or a quick
drag-and-release, which is what the reporter triggered by double-clicking — pointerUp's
document.exitPointerLock() is a no-op, so "pointerlockchange" never fires and the listeners
are never removed. The leaked pointermove listener then keeps updating the value on every mouse
move. Because an unpressed move reports e.buttons === 0 with e.button === -1, it also slips
past the existing exit guard (kept for a Firefox/Wayland quirk), so the value stays permanently
tied to the cursor.

Fix

  • Route every drag-exit path (pointerUp, right-click abort, buttons-released recovery) through a
    single endDrag() helper that cleans up directly when the pointer isn't actually locked
    (covers "lock hasn't engaged yet" and "pointer lock not in use"), instead of relying on
    "pointerlockchange".
  • Handle the requestPointerLock() promise so a lock that engages after the drag already
    ended exits itself immediately (no stuck-locked cursor), and a rejected request (browser re-lock
    cooldown) doesn't surface as an unhandled rejection.

Single file changed: frontend/src/components/widgets/inputs/NumberInput.svelte.

How I tested

There's no frontend test harness in the repo (CI is svelte-check + eslint), and the bug is a
timing-dependent race that's hard to hit by hand, so I verified it by deterministically forcing
the race
in the running editor.

Static checks

  • svelte-check passes (type-correct).
  • eslint on the changed file: 0 errors.

Deterministic before/after in the running dev build (Chrome/Chromium, usePointerLock === true)

I temporarily stubbed requestPointerLock to return a promise that resolves after release
(exactly the slow-lock condition behind the bug), dispatched pointerdown → pointermove → pointerup on a real Increment number field, then moved the mouse with no button pressed:

Code Value before release Value after no-button mouse move Result
Before fix 4 0 ❌ value tied to cursor
After fix 100% 100% ✅ value unchanged

A value change on a no-button move is only possible while the leaked pointermove listener is
still attached, so the "after" result (no change) confirms the listener is properly removed. The
editor also builds and runs normally with the change.

In Increment mode, dragging a number field uses the asynchronous Pointer
Lock API. `requestPointerLock()` only takes effect once the
"pointerlockchange" event fires, and the drag's cleanup (which removes the
window `pointermove`/`pointerup` listeners) was driven exclusively by that
event via `exitPointerLock()`.

When the interaction ended before the lock engaged — e.g. a rapid click or
a quick drag-and-release, as the reporter triggered by double-clicking —
`pointerUp`'s `exitPointerLock()` was a no-op, so "pointerlockchange" never
fired and the listeners were never removed. The leaked `pointermove`
listener then kept updating the value on every mouse move. Because an
unpressed move reports `e.buttons === 0` with `e.button === -1`, it also
slipped past the existing exit guard (kept for a Firefox/Wayland quirk), so
the value stayed permanently tied to the cursor.

Repro (Chrome): rapidly double-click a property slider; afterwards moving
the mouse with no button held keeps changing the value.

Fix: route every drag-exit path through a single `endDrag()` helper that
cleans up directly when the pointer isn't actually locked (covers the
lock-hasn't-engaged-yet and no-pointer-lock cases), instead of relying on
the "pointerlockchange" event. Also handle the `requestPointerLock()`
promise so a lock that engages after the drag already ended exits itself
immediately, avoiding a stuck-locked cursor, and so a rejected request
(browser re-lock cooldown) doesn't surface as an unhandled rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request resolves a race condition in NumberInput.svelte where a pointer lock might engage after the user has already released the mouse button, potentially leaving the cursor stuck. It introduces a dragReleased flag and a consolidated endDrag cleanup function to handle asynchronous pointer lock requests. The review feedback points out that checking document.pointerLockElement globally is too broad and could release locks belonging to other elements; it is recommended to explicitly verify that document.pointerLockElement === target before calling document.exitPointerLock().

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +435 to +440
if (lockRequest instanceof Promise) {
lockRequest.then(
() => {
// If the user already released before the lock engaged, exit it immediately so we aren't stuck locked.
if (dragReleased && document.pointerLockElement) document.exitPointerLock();
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Checking document.pointerLockElement is too broad here. If another element or widget has requested and acquired pointer lock in the meantime, calling document.exitPointerLock() would incorrectly release their lock instead of ours. We should explicitly check if document.pointerLockElement === target to ensure we only exit the lock if it belongs to this specific input element.

			if (lockRequest instanceof Promise) {
				lockRequest.then(
					() => {
						// If the user already released before the lock engaged, exit it immediately so we aren't stuck locked.
						if (dragReleased && document.pointerLockElement === target) document.exitPointerLock();
					},

Comment on lines +527 to +532
const endDrag = () => {
dragReleased = true;

if (usePointerLock && document.pointerLockElement) document.exitPointerLock();
else pointerLockChange();
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Similarly, we should only call document.exitPointerLock() if the active pointer lock belongs to our target. If it belongs to another element, we should bypass exiting it and directly call pointerLockChange() to clean up our own listeners immediately.

		const endDrag = () => {
			dragReleased = true;

			if (usePointerLock && document.pointerLockElement === target) document.exitPointerLock();
			else pointerLockChange();
		};

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 1 file

Confidence score: 3/5

  • In frontend/src/components/widgets/inputs/NumberInput.svelte, the pointer-lock cleanup logic checks document.pointerLockElement too broadly and may call document.exitPointerLock() even when a different element owns the lock, which can interrupt unrelated pointer-locked interactions and cause confusing input behavior — only exit when document.pointerLockElement === target, otherwise continue with the normal pointer-lock request path before merging.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="frontend/src/components/widgets/inputs/NumberInput.svelte">

<violation number="1" location="frontend/src/components/widgets/inputs/NumberInput.svelte:439">
P2: Checking `document.pointerLockElement` without comparing to `target` is too broad. If another element has acquired pointer lock between the request and this resolution, calling `document.exitPointerLock()` here would incorrectly release their lock. Use `document.pointerLockElement === target` to ensure we only exit our own lock.</violation>

<violation number="2" location="frontend/src/components/widgets/inputs/NumberInput.svelte:530">
P2: Same issue here: `document.pointerLockElement` should be compared to `target` to avoid releasing another element's pointer lock. If the lock belongs to a different element, we should fall through to calling `pointerLockChange()` directly to clean up our own listeners.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

const endDrag = () => {
dragReleased = true;

if (usePointerLock && document.pointerLockElement) document.exitPointerLock();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Same issue here: document.pointerLockElement should be compared to target to avoid releasing another element's pointer lock. If the lock belongs to a different element, we should fall through to calling pointerLockChange() directly to clean up our own listeners.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/components/widgets/inputs/NumberInput.svelte, line 530:

<comment>Same issue here: `document.pointerLockElement` should be compared to `target` to avoid releasing another element's pointer lock. If the lock belongs to a different element, we should fall through to calling `pointerLockChange()` directly to clean up our own listeners.</comment>

<file context>
@@ -497,6 +519,17 @@
+		const endDrag = () => {
+			dragReleased = true;
+
+			if (usePointerLock && document.pointerLockElement) document.exitPointerLock();
+			else pointerLockChange();
+		};
</file context>

lockRequest.then(
() => {
// If the user already released before the lock engaged, exit it immediately so we aren't stuck locked.
if (dragReleased && document.pointerLockElement) document.exitPointerLock();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Checking document.pointerLockElement without comparing to target is too broad. If another element has acquired pointer lock between the request and this resolution, calling document.exitPointerLock() here would incorrectly release their lock. Use document.pointerLockElement === target to ensure we only exit our own lock.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/src/components/widgets/inputs/NumberInput.svelte, line 439:

<comment>Checking `document.pointerLockElement` without comparing to `target` is too broad. If another element has acquired pointer lock between the request and this resolution, calling `document.exitPointerLock()` here would incorrectly release their lock. Use `document.pointerLockElement === target` to ensure we only exit our own lock.</comment>

<file context>
@@ -418,8 +418,33 @@
+				lockRequest.then(
+					() => {
+						// If the user already released before the lock engaged, exit it immediately so we aren't stuck locked.
+						if (dragReleased && document.pointerLockElement) document.exitPointerLock();
+					},
+					() => {
</file context>
Suggested change
if (dragReleased && document.pointerLockElement) document.exitPointerLock();
if (dragReleased && document.pointerLockElement === target) document.exitPointerLock();

@timon-schelling

Copy link
Copy Markdown
Member

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.

Values in property sliders getting tied to mouse position

2 participants