[Slider] A cancelled touch drag leaves document listeners armed; the next tap anywhere commits the stale value
- Dominant language
- TypeScript
- Stars
- 10.9k
- Forks
- 543
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 101
Description
# Bug report
## Current behavior
On touch input, a `Slider` drag that the browser ends with `touchcancel`/`pointercancel` instead of `touchend`/`pointerup` is never cleaned up. The control keeps its document-level `pointerup` listener and the last dragged value, so the next tap anywhere on the page fires `onValueCommitted` with that stale value, from a `pointerup` whose target is an unrelated element.
Recorded from the reproduction below (`window.commits` collects every `onValueCommitted` call):
```
after drag ended with touchCancel: []
after tap elsewhere: [{"value":70,"reason":"drag","eventType":"pointerup","target":"div#elsewhere"}]
```
Control run with the same drag ended by `touchEnd`: one commit at the end of the drag, nothing on later taps.
```
after drag ended with touchEnd: [{"value":70,"reason":"drag","eventType":"pointerup","target":"div"}]
after tap elsewhere: (unchanged)
```
Browsers cancel a touch whenever something else claims it: a system edge or navigation gesture, a second finger, a long-press context menu, the page starting to scroll. We hit this in a media scrubber on an Android WebView head unit, where every later tap on the screen seeked the audio back to the abandoned drag position.
## Expected behavior
A cancelled drag ends the interaction. No commit should come from a later, unrelated pointer, and the pending drag value should be dropped (or committed at cancel time, but never carried into the next gesture).
## Reproducible example
Single-file app, no styling beyond what makes the thumb hittable:
```tsx
import { Slider } from '@base-ui/react/slider';
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
window.commits = [];
function App() {
const [value, setValue] = useState(20);
return (
setValue(next)}
onValueCommitted={(next, details) => {
const target = details.event.target;
window.commits.push({
value: next,
reason: details.reason,
eventType: details.event.type,
target: target ? `${target.tagName.toLowerCase()}${target.id ? '#' + target.id : ''}` : 'null',
});
}}
style={{ width: 400 }}
>
tap here after a cancelled drag
);
}
createRoot(document.getElementById('root')).render();
```
Driven with Playwright (touch emulation, `touchCancel` sent through CDP because Playwright's touchscreen API only taps):
```js
import { chromium } from 'playwright';
const endType = process.argv[2] || 'touchCancel'; // pass touchEnd for the control run
const browser = await chromium.launch();
const page = await (await browser.newContext({ hasTouch: true })).newPage();
await page.goto('http://localhost:5173/'); // the app above
const cdp = await page.context().newCDPSession(page);
const thumb = await page.locator('#thumb').boundingBox();
const y = thumb.y + thumb.height / 2;
const x0 = thumb.x + thumb.width / 2;
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: x0, y }] });
for (let i = 1; i <= 10; i++) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: x0 + i * 20, y }] });
}
await cdp.send('Input.dispatchTouchEvent', { type: endType, touchPoints: [] });
console.log(`after drag ended with ${endType}:`, JSON.stringify(await page.evaluate(() => window.commits)));
const elsewhere = await page.locator('#elsewhere').boundingBox();
await page.touchscreen.tap(elsewhere.x + 40, elsewhere.y + 40);
console.log('after tap elsewhere:', JSON.stringify(await page.evaluate(() => window.commits)));
await browser.close();
```
## Base UI version
v1.8.0 (also present in v1.7.0)
## Which browser are you using?
Chromium 143 (Android WebView on an automotive head unit) and headless Chromium via Playwright 1.60 with touch emulation. A mouse never triggers it because the pointer is captured and `pointerup` always arrives.
## Which OS are you using?
Android 10 (WebView), macOS (Playwright)
## Which assistive tech are you using (if applicable)?
None
## Additional context
`SliderControl` arms four document listeners when a press starts: `pointermove` and `pointerup` (`{ once: true }`) from `onPointerDown`, and `touchmove` and `touchend` from the native `touchstart` listener. They are removed only by `stopListening()`, which runs from `handleTouchEnd`. Nothing in the control listens for `pointercancel` or `touchcancel`, so a cancelled gesture never reaches `handleTouchEnd`: `currentInteractionValueRef` keeps the last dragged value, and the next `pointerup` on the document, whatever its target, runs `handleTouchEnd` and commits it. This is still the case in `packages/react/src/slider/control/SliderControl.tsx` on master.
A likely fix is to register `pointercancel` and `touchcancel` next to the up/end listeners and route them to a handler that releases pointer capture, resets the pressed thumb and `currentInteractionValueRef`, and calls `stopListening()` without committing.
Contributor guide
Assessment
This issue has not been assessed yet.