Fix touch gestures cancelling, backward flicks, and late onCommit

- Only the host's own lostpointercapture cancels a gesture. Taking
  capture makes the touched child lose its implicit touch capture, and
  that bubbling event was cancelling every touch gesture immediately.
- A flick at least commitVelocity fast now decides by its direction:
  backward cancels even past the distance threshold, forward commits.
  Slower releases still decide by distance.
- Pointer-up finishes an already-begun operation without awaiting, so
  onCommit runs inside the pointer-up event, where iOS still allows
  haptics, audio, and clipboard writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
Claude
2026-10-31 05:41:51 +00:00
parent 7033814550
commit d4d7feb3b7
5 changed files with 112 additions and 22 deletions
+9 -6
View File
@@ -582,13 +582,16 @@ can run repeatedly during rendering and animation.
### Commit thresholds
When `finish()` does not explicitly override the decision, a target commits
when either:
When `finish()` does not explicitly override the decision, the release velocity
decides first, then distance:
- `progress >= commitThreshold`, default `0.36`; or
- release `velocity >= commitVelocity`, default `0.3` progress units per
second, at any progress. Velocity is normalized by the gesture host's size,
so `1` is one full width (or height) per second.
- a flick at least `commitVelocity` fast (default `0.3` progress units per
second) decides by its direction: forward commits, backward cancels,
regardless of progress;
- a slower release commits when `progress >= commitThreshold`, default `0.36`.
Velocity is normalized by the gesture host's size, so `1` is one full width (or
height) per second.
The operation's intent becomes final before its spring settles.
+31 -11
View File
@@ -303,6 +303,11 @@ interface PointerSession {
captured: boolean;
/** The scene operation, once navigation resolved and `begin()` was called. */
operation: Promise<OriginOperationHandle | null> | null;
/**
* The operation's handle once `begin()` resolved, so pointer-up can finish
* it synchronously, inside the event, without awaiting the promise.
*/
handle: OriginOperationHandle | null;
}
/**
@@ -368,6 +373,7 @@ export class GestureRecognizer {
),
captured: false,
operation: null,
handle: null,
};
}
@@ -388,14 +394,17 @@ export class GestureRecognizer {
this.session = null;
return;
}
session.operation = this.origin.begin(action).catch((error) => {
// Another recognizer on this origin won the race; that is expected.
if (!(
error instanceof OriginSceneError && error.code === "origin-busy"
))
warn("A gesture could not begin its operation.", error);
return null;
});
session.operation = this.origin.begin(action).then(
(handle) => (session.handle = handle),
(error) => {
// Another recognizer on this origin won the race; that is expected.
if (!(
error instanceof OriginSceneError && error.code === "origin-busy"
))
warn("A gesture could not begin its operation.", error);
return null;
},
);
} else {
event.preventDefault();
session.tracker.sample(event);
@@ -433,7 +442,11 @@ export class GestureRecognizer {
const commit = definition.completion?.(context);
const { progress, velocity } = tracker;
const handle = session.operation ? await session.operation : null;
// Usually the operation began long ago: finish it without awaiting, so
// commit handlers run inside this pointer-up (iOS only allows haptics,
// audio, and clipboard writes during the user's gesture).
const handle =
session.handle ?? (session.operation ? await session.operation : null);
if (!handle) return;
handle.update(progress, velocity);
// finish() fixes the outcome synchronously, before the spring settles.
@@ -445,9 +458,16 @@ export class GestureRecognizer {
await settled;
}
/** Pointer capture ended without a pointer-up; cancel like pointer-cancel. */
/**
* The host lost pointer capture without a pointer-up; cancel like
* pointer-cancel. The event bubbles, and taking capture makes the touched
* child lose its implicit touch capture, so only the host's own loss counts.
*/
pointerCaptureLost(event: PointerEvent): Promise<void> {
return this.sessionFor(event) ? this.abandon() : Promise.resolve();
const session = this.sessionFor(event);
return session && event.target === session.host
? this.abandon()
: Promise.resolve();
}
pointerCancel(): Promise<void> {
+10 -5
View File
@@ -73,17 +73,22 @@ export const DEFAULT_COMMIT_THRESHOLD = 0.36;
*/
export const DEFAULT_COMMIT_VELOCITY = 0.3;
/** Decide a release outcome from explicit options or progress/velocity. */
/**
* Decide a release outcome from explicit options, or from velocity and
* progress: a flick at least `commitVelocity` fast decides by its direction,
* forward committing and backward cancelling, regardless of distance. Slower
* releases commit once progress reaches `commitThreshold`.
*/
export const shouldCommit = (
operation: MutableOriginOperation,
options: OriginFinishOptions,
): boolean => {
if (options.commit !== undefined) return options.commit;
const { commitThreshold, commitVelocity } = operation.choreography;
return (
operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD) ||
operation.velocity >= (commitVelocity ?? DEFAULT_COMMIT_VELOCITY)
);
const flick = commitVelocity ?? DEFAULT_COMMIT_VELOCITY;
if (operation.velocity >= flick) return true;
if (operation.velocity <= -flick) return false;
return operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
};
/**
+26
View File
@@ -247,6 +247,32 @@ describe(".onCommit()", () => {
}
});
it("runs synchronously inside the pointer-up event", async () => {
let calledDuringDispatch = false;
let dispatching = false;
const { host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => {
calledDuringDispatch = dispatching;
})
.animate(motion),
);
host.dispatchEvent(pointer("pointerdown", 190, 0));
host.dispatchEvent(pointer("pointermove", 180, 500));
await flush();
host.dispatchEvent(pointer("pointermove", 40, 1000));
await flush();
// No awaiting between the event and the handler: iOS only allows
// haptics, audio, and clipboard writes during the user's gesture.
dispatching = true;
host.dispatchEvent(pointer("pointerup", 40, 1000));
dispatching = false;
expect(calledDuringDispatch).toBe(true);
});
it("fires at release, before the settling animation finishes", async () => {
const frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
+36
View File
@@ -108,6 +108,26 @@ describe("stuck gestures", () => {
expect(scene.nodes.value).toHaveLength(1);
});
it("ignores a child losing implicit touch capture to the host", async () => {
const Root = defineComponent({
setup: () => () =>
h(OriginGesture, { id: "host", gesture: definition }, () =>
h("span", { id: "child" }, "x"),
),
});
const { scene, host } = await mount(Root);
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
// Taking capture on the host makes the touched child lose its implicit
// capture; that event bubbles to the host.
host
.querySelector("#child")!
.dispatchEvent(pointer("lostpointercapture", 120, 110));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("ignores lostpointercapture after a normal release", async () => {
const { scene, host } = await mount(gestureHost(() => definition));
await dragLeft(host);
@@ -318,6 +338,22 @@ describe("commitVelocity", () => {
expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true);
});
it("lets a fast flick's direction veto the distance", () => {
const at = (progress: number, velocity: number) =>
({
progress,
velocity,
choreography: { effects: () => ({}) },
}) as unknown as Parameters<typeof shouldCommit>[0];
// Past the 0.36 threshold, but flicked back toward the start: cancels.
expect(shouldCommit(at(0.8, -0.5), {})).toBe(false);
// Barely started, but flicked forward: commits.
expect(shouldCommit(at(0.05, 0.5), {})).toBe(true);
// Slow releases fall back to distance, in either direction.
expect(shouldCommit(at(0.8, -0.1), {})).toBe(true);
expect(shouldCommit(at(0.2, 0.1), {})).toBe(false);
});
it("still yields to an explicit decision", () => {
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
});