From b6f782d8afe8dc182eb16273cec7f320fade31b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 04:09:57 +0000 Subject: [PATCH] Fix stuck gestures and scene graph races; honour commitVelocity Gestures - Cancel a captured gesture on lostpointercapture, on a new primary pointer-down, and when its component unmounts, so operations are never left stuck mid-transition. Bindings gain onLostpointercapture. - Warn (console.warn) when navigate() fails or the scene rejects an operation; stay quiet when two recognizers race for one origin. - OriginGesture and OriginGestureSurface props are reactive. Changes apply from the next pointer-down; recognizers are kept per list position so re-renders never interrupt a drag. - Remove the legacy direction/edge/threshold/action gesture form (OriginGestureOptions, legacy OriginGesture props, MaybeOriginAction). Scene - Honour choreography commitVelocity (default 0.3, as before). - Scene errors are OriginSceneError with a stable code. - update() during settling now really catches an operation: the spring stops, and only the latest finish() decides the outcome. - Fix graph corruption found by the new property tests, all present on main: replacements escaping a cancelled push, edges left dangling when their current target differs from the original one, revealed targets keeping operations after their back is cancelled, history pointing at removed entries after replace, visual cycles from back through an undecided replace, exposed nodes reactivated as active, and a cancel cascade that could empty the whole scene. Tests - Add property tests checking structural invariants after every step of random operation sequences and pointer streams (PROPERTY_SEEDS). - Add recognizer tests for stuck gestures, warnings, reactive props, and commitVelocity. Drop the main-comparison suites; keep benchmarks. Also fix the origins demo's two pre-existing type errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR --- .../src/views/NestedScenesView.vue | 5 +- packages/core-v2/API.md | 67 ++- packages/core-v2/src/choreography.ts | 8 +- .../core-v2/src/components/OriginGesture.vue | 16 +- .../src/components/OriginGestureSurface.vue | 38 +- packages/core-v2/src/gesture/definition.ts | 107 +--- packages/core-v2/src/gesture/recognizer.ts | 317 +++++----- packages/core-v2/src/index.ts | 1 + packages/core-v2/src/navigation.ts | 10 - packages/core-v2/src/property.test.ts | 555 ++++++++++++++++++ packages/core-v2/src/recognizer.test.ts | 324 ++++++++++ packages/core-v2/src/scene/graph.ts | 181 ++++-- packages/core-v2/src/scene/scene.ts | 105 +++- packages/core-v2/src/shared.ts | 6 + packages/core-v2/verify/README.md | 29 +- packages/core-v2/verify/gesture.diff.ts | 381 ------------ packages/core-v2/verify/harness.ts | 105 ---- packages/core-v2/verify/scene.diff.ts | 285 --------- packages/core-v2/verify/vitest.config.ts | 3 +- 19 files changed, 1380 insertions(+), 1163 deletions(-) create mode 100644 packages/core-v2/src/property.test.ts create mode 100644 packages/core-v2/src/recognizer.test.ts delete mode 100644 packages/core-v2/verify/gesture.diff.ts delete mode 100644 packages/core-v2/verify/scene.diff.ts diff --git a/apps/origins-demo/src/views/NestedScenesView.vue b/apps/origins-demo/src/views/NestedScenesView.vue index f36a81b..7687ec3 100644 --- a/apps/origins-demo/src/views/NestedScenesView.vue +++ b/apps/origins-demo/src/views/NestedScenesView.vue @@ -7,6 +7,7 @@ import { gesture, originView, useOrigin, + type OriginView, } from "@native-vue-router/core-v2"; import { useDemoInstance } from "../lab-state"; import { unfocusPortal } from "../motions"; @@ -86,7 +87,7 @@ const slideSets: Record = { ], }; -function nestedView(kind: NestedSceneKind, index: number) { +function nestedView(kind: NestedSceneKind, index: number): OriginView { const slides = slideSets[kind]; const copy = slides[index]!; return originView( @@ -98,7 +99,7 @@ function nestedView(kind: NestedSceneKind, index: number) { ...copy, next: index + 1 < slides.length - ? () => nestedView(kind, index + 1) + ? (): OriginView => nestedView(kind, index + 1) : undefined, }, { diff --git a/packages/core-v2/API.md b/packages/core-v2/API.md index c91414e..1bfed0e 100644 --- a/packages/core-v2/API.md +++ b/packages/core-v2/API.md @@ -145,7 +145,7 @@ recognizer to it. | Prop | Type | Default | Description | | --------- | ------------------------- | ------- | ---------------------------------- | | `as` | `string` | `"div"` | HTML tag used for the gesture host | -| `gesture` | `OriginGestureDefinition` | — | Preferred builder definition | +| `gesture` | `OriginGestureDefinition` | — | Builder definition | Attributes, classes, and listeners not consumed as props are forwarded to the rendered host. @@ -156,8 +156,9 @@ rendered host. ``` -For compatibility, the component also accepts the legacy mutually exclusive -set of `direction`, `edge`, `threshold`, and `action` props. +The `gesture` prop is reactive. A new definition applies from the next +pointer-down; a gesture already in progress keeps the definition it started +with, so a parent re-render never interrupts a drag. Use `useOriginGesture()` instead when an extra wrapper is undesirable. @@ -190,9 +191,11 @@ binding, and derives the least-permissive shared `touch-action`: - vertical only: `pan-x`; - both axes: `none`. -Definitions should be immutable and stable for the lifetime of the rendered -surface. The owning page remains the visible declaration point for every -`.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice. +The `gestures` list is reactive, including its length and the derived +`touch-action`. Recognizers are kept per list position, so a re-render with new +definition objects never interrupts a gesture in progress; each change applies +from the next pointer-down. The owning page remains the visible declaration +point for every `.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice. ## Nested scenes @@ -457,7 +460,8 @@ const openProfile = gesture.to ### `useOriginGesture(definition)` Creates one primary-pointer, single-axis recognizer owned by the component that -calls it. +calls it. `definition` may also be a ref or getter; a changed definition +applies from the next pointer-down. ```ts const open = useOriginGesture( @@ -477,6 +481,7 @@ interface OriginGestureBinding { readonly onPointermove: (event: PointerEvent) => void; readonly onPointerup: (event: PointerEvent) => void; readonly onPointercancel: () => void; + readonly onLostpointercapture: (event: PointerEvent) => void; } ``` @@ -497,8 +502,14 @@ is normalized by the same dimension. An asynchronous navigation factory is supported. If it resolves after the pointer was released or cancelled, the stale result is discarded. -The legacy `OriginGestureOptions` object remains accepted. Its `edge` is a -number inferred from the side opposite `direction`, matching the previous API. +A captured gesture is cancelled, never left mid-transition, when its pointer +ends without a pointer-up: on `pointercancel`, on `lostpointercapture`, when a +new primary pointer goes down, or when the component unmounts. + +Failures are reported with `console.warn` and the gesture is abandoned: a +`navigate()` factory that throws or rejects, or a scene that rejects the +operation (see `OriginSceneError`). Returning `null` declines silently, as +does losing a race to another gesture on the same origin. ## Choreographies and effects @@ -575,10 +586,9 @@ When `finish()` does not explicitly override the decision, a target commits when either: - `progress >= commitThreshold`, default `0.36`; or -- release `velocity >= 0.3` progress units per second, at any progress. - -`commitVelocity` is accepted on choreographies but is not currently consulted; -the flick threshold is the fixed `0.3` above. +- 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. The operation's intent becomes final before its spring settles. @@ -662,8 +672,13 @@ can immediately begin its own outgoing operation, enabling X → Y → Z chains. ### `OriginOperationHandle` -`intent` reports the selected outcome (`undecided`, `commit`, or `cancel`). It -changes synchronously when `finish()` or `cancel()` is called. +`intent` changes synchronously when `finish()` or `cancel()` is called, before +the settling spring runs. + +Calling `update()` while an operation settles catches it: the spring stops and +progress is under control again, for example to grab a transition that is +still animating. A later `finish()` decides the outcome, and any earlier +`finish()` promise resolves with that final outcome. | Member | Description | | ----------------------------- | --------------------------------------------------- | @@ -672,6 +687,7 @@ changes synchronously when `finish()` or `cancel()` is called. | `targetKey` | Created or retained target node key | | `update(progress, velocity?)` | Update normalized interactive state | | `finish(options?)` | Decide, settle, and return whether target committed | +| `intent` | Selected outcome: `undecided`, `commit`, `cancel` | | `cancel(options?)` | Force cancellation and remove the target branch | `OriginFinishOptions`: @@ -737,12 +753,6 @@ Node-local action context: - `previous`: recipe belonging to the mounted previous entry. - `history`: recipes belonging to all retained previous entries. -### `MaybeOriginAction` - -```ts -OriginAction | null | undefined | Promise; -``` - ### `OriginEffect` | Field | Description | @@ -786,6 +796,21 @@ implementation types. They are exported by the current barrel but marked ## Errors and constraints +Scene operations reject with `OriginSceneError`, whose `code` identifies the +cause: + +| Code | Cause | +| ------------------- | --------------------------------------------------- | +| `missing-node` | The node no longer exists | +| `missing-origin` | The origin node does not exist | +| `inert-origin` | The origin is parked or exposed | +| `origin-busy` | The origin already has a live outgoing operation | +| `invalid-action` | The action cannot be executed as written | +| `no-previous-entry` | Back was requested without a retained entry | +| `undecided-forward` | Back was requested through an unreleased forward | +| `target-busy` | The back target is transitioning or still presented | +| `outside-scene` | `useOrigin()` was called outside an `` | + - `useOrigin()` and `useOriginGesture()` must run inside a component mounted by `OriginScene`. - An origin can own only one outgoing operation at a time. diff --git a/packages/core-v2/src/choreography.ts b/packages/core-v2/src/choreography.ts index 0c4cb76..fc756a7 100644 --- a/packages/core-v2/src/choreography.ts +++ b/packages/core-v2/src/choreography.ts @@ -115,11 +115,11 @@ export interface OriginChoreography { */ readonly commitThreshold?: number; /** - * Reserved: normalized positive release velocity intended to commit a - * deliberate flick. + * Release velocity, in progress units per second, that commits a flick + * regardless of progress. Velocity is normalized by the gesture host's + * size, so `1` means one full host width (or height) per second. * - * The scene currently commits any release whose velocity is at least `0.3`, - * regardless of this value. See `FLICK_COMMIT_VELOCITY` in the scene module. + * @defaultValue `0.3` */ readonly commitVelocity?: number; /** diff --git a/packages/core-v2/src/components/OriginGesture.vue b/packages/core-v2/src/components/OriginGesture.vue index 0689f53..62f09ad 100644 --- a/packages/core-v2/src/components/OriginGesture.vue +++ b/packages/core-v2/src/components/OriginGesture.vue @@ -1,6 +1,6 @@