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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
Claude
2026-10-16 04:09:57 +00:00
parent 46f030630b
commit b6f782d8af
19 changed files with 1380 additions and 1163 deletions
@@ -7,6 +7,7 @@ import {
gesture, gesture,
originView, originView,
useOrigin, useOrigin,
type OriginView,
} from "@native-vue-router/core-v2"; } from "@native-vue-router/core-v2";
import { useDemoInstance } from "../lab-state"; import { useDemoInstance } from "../lab-state";
import { unfocusPortal } from "../motions"; import { unfocusPortal } from "../motions";
@@ -86,7 +87,7 @@ const slideSets: Record<NestedSceneKind, readonly SlideCopy[]> = {
], ],
}; };
function nestedView(kind: NestedSceneKind, index: number) { function nestedView(kind: NestedSceneKind, index: number): OriginView {
const slides = slideSets[kind]; const slides = slideSets[kind];
const copy = slides[index]!; const copy = slides[index]!;
return originView( return originView(
@@ -98,7 +99,7 @@ function nestedView(kind: NestedSceneKind, index: number) {
...copy, ...copy,
next: next:
index + 1 < slides.length index + 1 < slides.length
? () => nestedView(kind, index + 1) ? (): OriginView => nestedView(kind, index + 1)
: undefined, : undefined,
}, },
{ {
+46 -21
View File
@@ -145,7 +145,7 @@ recognizer to it.
| Prop | Type | Default | Description | | Prop | Type | Default | Description |
| --------- | ------------------------- | ------- | ---------------------------------- | | --------- | ------------------------- | ------- | ---------------------------------- |
| `as` | `string` | `"div"` | HTML tag used for the gesture host | | `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 Attributes, classes, and listeners not consumed as props are forwarded to the
rendered host. rendered host.
@@ -156,8 +156,9 @@ rendered host.
</OriginGesture> </OriginGesture>
``` ```
For compatibility, the component also accepts the legacy mutually exclusive The `gesture` prop is reactive. A new definition applies from the next
set of `direction`, `edge`, `threshold`, and `action` props. 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. 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`; - vertical only: `pan-x`;
- both axes: `none`. - both axes: `none`.
Definitions should be immutable and stable for the lifetime of the rendered The `gestures` list is reactive, including its length and the derived
surface. The owning page remains the visible declaration point for every `touch-action`. Recognizers are kept per list position, so a re-render with new
`.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice. 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 ## Nested scenes
@@ -457,7 +460,8 @@ const openProfile = gesture.to
### `useOriginGesture(definition)` ### `useOriginGesture(definition)`
Creates one primary-pointer, single-axis recognizer owned by the component that 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 ```ts
const open = useOriginGesture( const open = useOriginGesture(
@@ -477,6 +481,7 @@ interface OriginGestureBinding {
readonly onPointermove: (event: PointerEvent) => void; readonly onPointermove: (event: PointerEvent) => void;
readonly onPointerup: (event: PointerEvent) => void; readonly onPointerup: (event: PointerEvent) => void;
readonly onPointercancel: () => 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 An asynchronous navigation factory is supported. If it resolves after the
pointer was released or cancelled, the stale result is discarded. pointer was released or cancelled, the stale result is discarded.
The legacy `OriginGestureOptions` object remains accepted. Its `edge` is a A captured gesture is cancelled, never left mid-transition, when its pointer
number inferred from the side opposite `direction`, matching the previous API. 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 ## Choreographies and effects
@@ -575,10 +586,9 @@ When `finish()` does not explicitly override the decision, a target commits
when either: when either:
- `progress >= commitThreshold`, default `0.36`; or - `progress >= commitThreshold`, default `0.36`; or
- release `velocity >= 0.3` progress units per second, at any progress. - release `velocity >= commitVelocity`, default `0.3` progress units per
second, at any progress. Velocity is normalized by the gesture host's size,
`commitVelocity` is accepted on choreographies but is not currently consulted; so `1` is one full width (or height) per second.
the flick threshold is the fixed `0.3` above.
The operation's intent becomes final before its spring settles. 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` ### `OriginOperationHandle`
`intent` reports the selected outcome (`undecided`, `commit`, or `cancel`). It `intent` changes synchronously when `finish()` or `cancel()` is called, before
changes synchronously when `finish()` or `cancel()` is called. 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 | | Member | Description |
| ----------------------------- | --------------------------------------------------- | | ----------------------------- | --------------------------------------------------- |
@@ -672,6 +687,7 @@ changes synchronously when `finish()` or `cancel()` is called.
| `targetKey` | Created or retained target node key | | `targetKey` | Created or retained target node key |
| `update(progress, velocity?)` | Update normalized interactive state | | `update(progress, velocity?)` | Update normalized interactive state |
| `finish(options?)` | Decide, settle, and return whether target committed | | `finish(options?)` | Decide, settle, and return whether target committed |
| `intent` | Selected outcome: `undecided`, `commit`, `cancel` |
| `cancel(options?)` | Force cancellation and remove the target branch | | `cancel(options?)` | Force cancellation and remove the target branch |
`OriginFinishOptions`: `OriginFinishOptions`:
@@ -737,12 +753,6 @@ Node-local action context:
- `previous`: recipe belonging to the mounted previous entry. - `previous`: recipe belonging to the mounted previous entry.
- `history`: recipes belonging to all retained previous entries. - `history`: recipes belonging to all retained previous entries.
### `MaybeOriginAction`
```ts
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
```
### `OriginEffect` ### `OriginEffect`
| Field | Description | | Field | Description |
@@ -786,6 +796,21 @@ implementation types. They are exported by the current barrel but marked
## Errors and constraints ## 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 `<OriginScene>` |
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by - `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
`OriginScene`. `OriginScene`.
- An origin can own only one outgoing operation at a time. - An origin can own only one outgoing operation at a time.
+4 -4
View File
@@ -115,11 +115,11 @@ export interface OriginChoreography {
*/ */
readonly commitThreshold?: number; readonly commitThreshold?: number;
/** /**
* Reserved: normalized positive release velocity intended to commit a * Release velocity, in progress units per second, that commits a flick
* deliberate 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`, * @defaultValue `0.3`
* regardless of this value. See `FLICK_COMMIT_VELOCITY` in the scene module.
*/ */
readonly commitVelocity?: number; readonly commitVelocity?: number;
/** /**
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useOriginGesture } from "../gesture/recognizer";
import type { OriginGestureProps } from "../gesture/definition"; import type { OriginGestureProps } from "../gesture/definition";
import { useOriginGesture } from "../gesture/recognizer";
defineOptions({ name: "OriginGesture", inheritAttrs: false }); defineOptions({ name: "OriginGesture", inheritAttrs: false });
@@ -11,17 +11,10 @@ const props = withDefaults(defineProps<OriginGestureProps>(), {
/* /*
* This convenience component makes the declaration live exactly where the * This convenience component makes the declaration live exactly where the
* developer writes it. `useOriginGesture()` is also public for components that * developer writes it. `useOriginGesture()` is also public for components that
* cannot accept an extra wrapper element. Props are read once, at setup; the * cannot accept an extra wrapper element. A new `gesture` prop applies from
* legacy `action` prop is read lazily so it may change. * the next pointer-down.
*/ */
const gesture = useOriginGesture( const gesture = useOriginGesture(() => props.gesture);
props.gesture ?? {
direction: props.direction,
edge: props.edge,
threshold: props.threshold,
action: (context) => props.action?.(context),
},
);
</script> </script>
<template> <template>
@@ -34,6 +27,7 @@ const gesture = useOriginGesture(
@pointermove="gesture.onPointermove" @pointermove="gesture.onPointermove"
@pointerup="gesture.onPointerup" @pointerup="gesture.onPointerup"
@pointercancel="gesture.onPointercancel" @pointercancel="gesture.onPointercancel"
@lostpointercapture="gesture.onLostpointercapture"
> >
<slot /> <slot />
</component> </component>
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
import type { OriginGestureSurfaceProps } from "../gesture/definition"; import type { OriginGestureSurfaceProps } from "../gesture/definition";
import { touchActionFor, useOriginGesture } from "../gesture/recognizer"; import { useOriginGestures } from "../gesture/recognizer";
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false }); defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
@@ -14,29 +13,7 @@ const props = withDefaults(defineProps<OriginGestureSurfaceProps>(), {
* This convenience host only installs them, combines their browser scrolling * This convenience host only installs them, combines their browser scrolling
* requirements, and forwards a pointer sequence to every recognizer. * requirements, and forwards a pointer sequence to every recognizer.
*/ */
const bindings = props.gestures.map((definition) => const surface = useOriginGestures(() => props.gestures);
useOriginGesture(definition),
);
const surfaceStyle = computed(
() =>
({
width: "100%",
height: "100%",
touchAction: touchActionFor(
props.gestures.map(({ direction }) => direction),
),
}) as const,
);
const onPointerdown = (event: PointerEvent) =>
bindings.forEach((binding) => binding.onPointerdown(event));
const onPointermove = (event: PointerEvent) =>
bindings.forEach((binding) => binding.onPointermove(event));
const onPointerup = (event: PointerEvent) =>
bindings.forEach((binding) => binding.onPointerup(event));
const onPointercancel = () =>
bindings.forEach((binding) => binding.onPointercancel());
</script> </script>
<template> <template>
@@ -44,11 +21,12 @@ const onPointercancel = () =>
:is="as" :is="as"
v-bind="$attrs" v-bind="$attrs"
class="nvo-gesture" class="nvo-gesture"
:style="[$attrs.style, surfaceStyle]" :style="[$attrs.style, surface.style]"
@pointerdown="onPointerdown" @pointerdown="surface.onPointerdown"
@pointermove="onPointermove" @pointermove="surface.onPointermove"
@pointerup="onPointerup" @pointerup="surface.onPointerup"
@pointercancel="onPointercancel" @pointercancel="surface.onPointercancel"
@lostpointercapture="surface.onLostpointercapture"
> >
<slot /> <slot />
</component> </component>
+23 -84
View File
@@ -1,6 +1,6 @@
import type { CSSProperties } from "vue"; import type { CSSProperties } from "vue";
import type { OriginChoreography } from "../choreography"; import type { OriginChoreography } from "../choreography";
import type { MaybeOriginAction, OriginNavigationIntent } from "../navigation"; import type { OriginNavigationIntent } from "../navigation";
import type { OriginContext } from "../scene/types"; import type { OriginContext } from "../scene/types";
import type { OriginRect } from "../shared"; import type { OriginRect } from "../shared";
@@ -152,7 +152,7 @@ export type OriginGestureNavigationFactory = (
* represented as an `anywhere` start rule. * represented as an `anywhere` start rule.
*/ */
export interface OriginGestureDefinition { export interface OriginGestureDefinition {
/** Discriminator used by the compatibility recognizer overload. */ /** Identifies a definition produced by the {@link gesture} builder. */
readonly kind: "origin-gesture-definition"; readonly kind: "origin-gesture-definition";
/** Pointer-down eligibility policy. */ /** Pointer-down eligibility policy. */
readonly start: OriginGestureStart; readonly start: OriginGestureStart;
@@ -260,47 +260,17 @@ export interface OriginGestureBuilder {
readonly to: OriginGestureToBuilder; readonly to: OriginGestureToBuilder;
} }
/** Configuration consumed by {@link useOriginGesture}. */
export interface OriginGestureOptions {
/**
* Direction in which the pointer moves to advance the operation.
*
* The starting edge is the opposite side: `right` begins at the left edge,
* `left` at the right edge, `down` at the top, and `up` at the bottom.
*/
direction: OriginGestureDirection;
/**
* Restrict pointer-down to this many CSS pixels from the gesture host's
* starting edge. Omit it to recognize across the entire host.
*
* This is relative to the bound element, not necessarily the browser
* viewport. Use a positive number such as `24` or `36`.
*/
edge?: number;
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* Movement must also dominate the cross-axis by a factor of `1.15`.
*
* @defaultValue `8`
*/
threshold?: number;
/**
* Resolve the scene action after directional recognition succeeds.
*
* Returning no action abandons recognition without modifying the scene.
*/
action(context: OriginContext): MaybeOriginAction;
}
/** /**
* DOM bindings returned by {@link useOriginGesture}. * DOM bindings returned by {@link useOriginGesture}.
* *
* Spread or attach all four handlers to the same `HTMLElement`. Apply * Attach all five handlers to the same `HTMLElement`. Apply {@link style} as
* {@link style} as well so native scrolling is preserved on the cross-axis. * well so native scrolling is preserved on the cross-axis.
*/ */
export interface OriginGestureBinding { export interface OriginGestureBinding {
/** Required size and `touch-action` styles for the gesture host. */ /**
* Required size and `touch-action` styles for the gesture host. Reading it
* inside a render function tracks changes to the gesture definitions.
*/
readonly style: Readonly<CSSProperties>; readonly style: Readonly<CSSProperties>;
/** Pointer-down handler that records a potentially eligible gesture. */ /** Pointer-down handler that records a potentially eligible gesture. */
readonly onPointerdown: (event: PointerEvent) => void; readonly onPointerdown: (event: PointerEvent) => void;
@@ -310,64 +280,31 @@ export interface OriginGestureBinding {
readonly onPointerup: (event: PointerEvent) => void; readonly onPointerup: (event: PointerEvent) => void;
/** Pointer-cancel handler that abandons any captured operation. */ /** Pointer-cancel handler that abandons any captured operation. */
readonly onPointercancel: () => void; readonly onPointercancel: () => void;
/**
* Lost-capture handler. Cancels a captured gesture whose pointer capture
* ends without a pointer-up, so the operation never stays stuck mid-way.
*/
readonly onLostpointercapture: (event: PointerEvent) => void;
} }
// Component props ---------------------------------------------------------- // Component props ----------------------------------------------------------
/** Props shared by both `OriginGesture` declaration styles. */ /** Public props accepted by the `OriginGesture` convenience component. */
export interface OriginGestureBaseProps { export interface OriginGestureProps {
/** /**
* Native HTML tag rendered as the gesture host. * Native HTML tag rendered as the gesture host.
* *
* @defaultValue `"div"` * @defaultValue `"div"`
*/ */
as?: string; as?: string;
} /**
* Immutable definition produced by the {@link gesture} builder. Replacing
/** Builder-definition props accepted by the `OriginGesture` component. */ * it takes effect from the next pointer-down; a gesture in progress keeps
export interface OriginGestureDefinitionProps extends OriginGestureBaseProps { * the definition it started with.
/** Immutable definition produced by the {@link gesture} builder. */ */
gesture: OriginGestureDefinition; gesture: OriginGestureDefinition;
/** Builder definitions already contain direction. */
direction?: never;
/** Builder definitions already contain their start policy. */
edge?: never;
/** Builder definitions already contain recognition tuning. */
threshold?: never;
/** Builder definitions already contain their navigation factory. */
action?: never;
} }
/** Legacy option props accepted by the `OriginGesture` component. */
export interface OriginGestureLegacyProps extends OriginGestureBaseProps {
/** Legacy component declarations do not provide a builder definition. */
gesture?: never;
/** Direction in which pointer movement advances operation progress. */
direction: OriginGestureDirection;
/**
* Eligible pointer-down width in CSS pixels from the host's starting edge.
* Omit it to allow the full component surface.
*/
edge?: number;
/**
* Directed movement required before capture.
*
* @defaultValue `8`
*/
threshold?: number;
/** Node-local action factory invoked only after recognition succeeds. */
action(context: OriginContext): MaybeOriginAction;
}
/**
* Public props accepted by the `OriginGesture` convenience component.
*
* Prefer the builder-definition form. The legacy direction/action form remains
* available for compatibility.
*/
export type OriginGestureProps =
OriginGestureDefinitionProps | OriginGestureLegacyProps;
/** Public props accepted by the multi-definition gesture surface component. */ /** Public props accepted by the multi-definition gesture surface component. */
export interface OriginGestureSurfaceProps { export interface OriginGestureSurfaceProps {
/** /**
@@ -380,7 +317,9 @@ export interface OriginGestureSurfaceProps {
* Complete immutable definitions installed on the shared host. * Complete immutable definitions installed on the shared host.
* *
* The surface adds no start, direction, completion, navigation, or animation * The surface adds no start, direction, completion, navigation, or animation
* policy. Definitions should be created by the owning page component. * policy. Definitions should be created by the owning page component. The
* list may change; a gesture in progress keeps the definition it started
* with.
*/ */
gestures: readonly OriginGestureDefinition[]; gestures: readonly OriginGestureDefinition[];
} }
+159 -158
View File
@@ -1,16 +1,15 @@
import type { OriginAction, MaybeOriginAction } from "../navigation"; import { computed, onScopeDispose, toValue, type MaybeRefOrGetter } from "vue";
import type { OriginAction } from "../navigation";
import { OriginSceneError } from "../scene/graph";
import { useOrigin, type UseOrigin } from "../scene/scene"; import { useOrigin, type UseOrigin } from "../scene/scene";
import type { OriginContext, OriginOperationHandle } from "../scene/types"; import type { OriginContext, OriginOperationHandle } from "../scene/types";
import { clamp01, measureRect, type OriginRect } from "../shared"; import { clamp01, measureRect, warn, type OriginRect } from "../shared";
import type { import type {
OriginGestureBinding, OriginGestureBinding,
OriginGestureCommitHandler,
OriginGestureCompletionContext,
OriginGestureCompletionPredicate,
OriginGestureDefinition, OriginGestureDefinition,
OriginGestureCompletionContext,
OriginGestureDirection, OriginGestureDirection,
OriginGestureDistance, OriginGestureDistance,
OriginGestureOptions,
OriginGesturePoint, OriginGesturePoint,
OriginGestureStart, OriginGestureStart,
} from "./definition"; } from "./definition";
@@ -285,33 +284,13 @@ const DEFAULT_THRESHOLD = 8;
/** Ratio by which directed movement must exceed cross-axis movement. */ /** Ratio by which directed movement must exceed cross-axis movement. */
const DEFAULT_AXIS_DOMINANCE = 1.15; const DEFAULT_AXIS_DOMINANCE = 1.15;
/** Legacy `edge` start side for each movement direction. */
const LEGACY_START_EDGE = {
left: "right",
right: "left",
up: "bottom",
down: "top",
} as const;
/**
* The single shape the recognizer executes. Builder definitions and legacy
* options objects are both normalized into it.
*/
export interface RecognizerPolicy {
readonly direction: OriginGestureDirection;
readonly start: OriginGestureStart;
readonly threshold: number;
readonly axisDominance: number;
readonly completion?: OriginGestureCompletionPredicate;
readonly commitHandlers: readonly OriginGestureCommitHandler[];
readonly action: (context: OriginContext) => MaybeOriginAction;
}
/** The origin capabilities a recognizer needs. */ /** The origin capabilities a recognizer needs. */
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">; export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
/** State of one admitted pointer sequence, from pointer-down to release. */ /** State of one admitted pointer sequence, from pointer-down to release. */
interface PointerSession { interface PointerSession {
/** Definition current at pointer-down; later changes do not affect it. */
readonly definition: OriginGestureDefinition;
readonly pointerId: number; readonly pointerId: number;
readonly host: HTMLElement; readonly host: HTMLElement;
readonly bounds: OriginRect; readonly bounds: OriginRect;
@@ -322,7 +301,7 @@ interface PointerSession {
readonly tracker: DirectionalTracker; readonly tracker: DirectionalTracker;
/** Movement passed the recognition threshold and the pointer is captured. */ /** Movement passed the recognition threshold and the pointer is captured. */
captured: boolean; captured: boolean;
/** The scene operation, once the action resolved and `begin()` was called. */ /** The scene operation, once navigation resolved and `begin()` was called. */
operation: Promise<OriginOperationHandle | null> | null; operation: Promise<OriginOperationHandle | null> | null;
} }
@@ -333,119 +312,58 @@ interface PointerSession {
* *
* 1. **down**: an eligible primary pointer opens a session. * 1. **down**: an eligible primary pointer opens a session.
* 2. **move**: once directed movement passes the threshold, the pointer is * 2. **move**: once directed movement passes the threshold, the pointer is
* captured and the policy's action is resolved; a non-null action begins * captured and `.navigate()` is resolved; a non-null intent begins an
* an operation, while a declined one closes the session. Every later move * operation, while a declined one closes the session. Every later move
* updates the operation's progress. * updates the operation's progress.
* 3. **up**: the session closes and the operation finishes, using the * 3. **up**: the session closes and the operation finishes, using the
* policy's completion predicate when present. * definition's `.complete()` predicate when present.
* 4. **cancel**: the session closes and the operation is cancelled. * 4. **cancel**: pointer-cancel, lost pointer capture, a new primary
* pointer-down, or disposal closes the session and cancels its operation.
* *
* Action resolution and `begin()` are asynchronous. Each continuation checks * Navigation and `begin()` are asynchronous. Each continuation checks that
* that its session is still current, so a stale resolution never creates or * its session is still current, so a stale resolution never creates or
* updates an orphan operation. * updates an orphan operation.
*/ */
export class GestureRecognizer { export class GestureRecognizer {
private session: PointerSession | null = null; private session: PointerSession | null = null;
constructor( constructor(
readonly policy: RecognizerPolicy, /** Reads the current definition; it may change between sessions. */
private readonly definitionOf: () => OriginGestureDefinition,
private readonly origin: RecognizerOrigin, private readonly origin: RecognizerOrigin,
) {} ) {}
/** Create a recognizer for a builder definition or legacy options. */
static create(
definition: OriginGestureDefinition | OriginGestureOptions,
origin: RecognizerOrigin,
): GestureRecognizer {
return new GestureRecognizer(
GestureRecognizer.policyFor(definition),
origin,
);
}
/**
* Normalize a builder definition or legacy options object into the single
* policy shape the recognizer executes.
*/
static policyFor(
definition: OriginGestureDefinition | OriginGestureOptions,
): RecognizerPolicy {
return "kind" in definition &&
definition.kind === "origin-gesture-definition"
? GestureRecognizer.policyFromDefinition(definition)
: GestureRecognizer.policyFromOptions(definition as OriginGestureOptions);
}
private static policyFromDefinition(
definition: OriginGestureDefinition,
): RecognizerPolicy {
return {
direction: definition.direction,
start: definition.start,
threshold: definition.recognition.threshold ?? DEFAULT_THRESHOLD,
axisDominance:
definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE,
completion: definition.completion,
commitHandlers: definition.commitHandlers ?? [],
// Pair the resolved navigation intent with the definition's animation.
action: async (context) => {
const navigation = await definition.navigation(context);
if (!navigation) return navigation;
const action: OriginAction = {
...navigation,
choreography: definition.choreography,
};
return action;
},
};
}
/** Legacy `edge` is measured from the side opposite the movement. */
private static policyFromOptions(
options: OriginGestureOptions,
): RecognizerPolicy {
return {
direction: options.direction,
start:
options.edge === undefined
? { kind: "anywhere" }
: Object.freeze({
kind: "edge",
edge: LEGACY_START_EDGE[options.direction],
distance: options.edge,
}),
threshold: options.threshold ?? DEFAULT_THRESHOLD,
axisDominance: DEFAULT_AXIS_DOMINANCE,
action: options.action,
commitHandlers: [],
};
}
pointerDown(event: PointerEvent): void { pointerDown(event: PointerEvent): void {
if (!event.isPrimary) return;
// Only one primary pointer exists at a time, so any session still open
// lost its pointer-up (for example, released outside the window).
void this.abandon();
const host = event.currentTarget; const host = event.currentTarget;
if ( if (
!event.isPrimary ||
event.button !== 0 || event.button !== 0 ||
!(host instanceof HTMLElement) || !(host instanceof HTMLElement) ||
isIgnoredTarget(event.target) isIgnoredTarget(event.target)
) )
return; return;
const definition = this.definitionOf();
const bounds = measureRect(host); const bounds = measureRect(host);
const origin = this.origin.context.value; const origin = this.origin.context.value;
if (!matchesStart(this.policy.start, event, host, origin, bounds)) return; if (!matchesStart(definition.start, event, host, origin, bounds)) return;
// The component containing this declaration is the operation's origin. // The component containing this declaration is the operation's origin.
event.stopPropagation(); event.stopPropagation();
const horizontal = isHorizontal(this.policy.direction); const horizontal = isHorizontal(definition.direction);
this.session = { this.session = {
definition,
pointerId: event.pointerId, pointerId: event.pointerId,
host, host,
bounds, bounds,
origin, origin,
start: pointOf(event, bounds), start: pointOf(event, bounds),
startTime: event.timeStamp, startTime: event.timeStamp,
tracker: new DirectionalTracker(this.policy.direction, event, () => tracker: new DirectionalTracker(definition.direction, event, () =>
horizontal ? host.clientWidth : host.clientHeight, horizontal ? host.clientWidth : host.clientHeight,
), ),
captured: false, captured: false,
@@ -458,19 +376,26 @@ export class GestureRecognizer {
if (!session) return; if (!session) return;
if (!session.captured) { if (!session.captured) {
if (!this.recognizes(session, event)) return; if (!GestureRecognizer.recognizes(session, event)) return;
session.captured = true; session.captured = true;
session.host.setPointerCapture?.(session.pointerId); session.host.setPointerCapture?.(session.pointerId);
event.preventDefault(); event.preventDefault();
session.tracker.sample(event); session.tracker.sample(event);
const action = await this.resolveAction(); const action = await this.resolveAction(session.definition);
// The pointer may have been released or cancelled meanwhile. // The pointer may have been released or cancelled meanwhile.
if (this.session !== session) return; if (this.session !== session) return;
if (!action) { if (!action) {
this.session = null; this.session = null;
return; return;
} }
session.operation = this.origin.begin(action).catch(() => null); 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;
});
} else { } else {
event.preventDefault(); event.preventDefault();
session.tracker.sample(event); session.tracker.sample(event);
@@ -489,11 +414,11 @@ export class GestureRecognizer {
this.session = null; this.session = null;
if (!session.captured) return; if (!session.captured) return;
const { tracker } = session; const { tracker, definition } = session;
tracker.sample(event, true); tracker.sample(event, true);
const context: OriginGestureCompletionContext = { const context: OriginGestureCompletionContext = {
origin: session.origin, origin: session.origin,
direction: this.policy.direction, direction: definition.direction,
progress: tracker.progress, progress: tracker.progress,
velocity: tracker.velocity, velocity: tracker.velocity,
distance: tracker.distance, distance: tracker.distance,
@@ -505,7 +430,7 @@ export class GestureRecognizer {
start: session.start, start: session.start,
current: pointOf(event, session.bounds), current: pointOf(event, session.bounds),
}; };
const commit = this.policy.completion?.(context); const commit = definition.completion?.(context);
const { progress, velocity } = tracker; const { progress, velocity } = tracker;
const handle = session.operation ? await session.operation : null; const handle = session.operation ? await session.operation : null;
@@ -515,24 +440,22 @@ export class GestureRecognizer {
const settled = handle.finish( const settled = handle.finish(
commit === undefined ? undefined : { commit }, commit === undefined ? undefined : { commit },
); );
if (handle.intent === "commit") this.notifyCommit(context); if (handle.intent === "commit")
GestureRecognizer.notifyCommit(definition, context);
await settled; await settled;
} }
/** Run commit handlers; a failing handler never interrupts the others. */ /** Pointer capture ended without a pointer-up; cancel like pointer-cancel. */
private notifyCommit(context: OriginGestureCompletionContext) { pointerCaptureLost(event: PointerEvent): Promise<void> {
for (const handler of this.policy.commitHandlers) { return this.sessionFor(event) ? this.abandon() : Promise.resolve();
try {
handler(context);
} catch (error) {
queueMicrotask(() => {
throw error;
});
}
}
} }
async pointerCancel(): Promise<void> { pointerCancel(): Promise<void> {
return this.abandon();
}
/** Close the current session, cancelling its operation if captured. */
async abandon(): Promise<void> {
const session = this.session; const session = this.session;
this.session = null; this.session = null;
if (!session?.captured) return; if (!session?.captured) return;
@@ -546,30 +469,122 @@ export class GestureRecognizer {
} }
/** Directed movement passed the threshold and dominates the cross axis. */ /** Directed movement passed the threshold and dominates the cross axis. */
private recognizes(session: PointerSession, event: PointerEvent) { private static recognizes(session: PointerSession, event: PointerEvent) {
const { recognition } = session.definition;
const { distance, crossDistance } = session.tracker.displacement(event); const { distance, crossDistance } = session.tracker.displacement(event);
return ( return (
distance >= this.policy.threshold && distance >= (recognition.threshold ?? DEFAULT_THRESHOLD) &&
distance >= crossDistance * this.policy.axisDominance distance >=
crossDistance * (recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE)
); );
} }
/** /**
* Resolve the policy's action against the origin's current context. A * Resolve the definition's navigation against the origin's current context
* declined or failed action resolves to `null`. * and pair it with its choreography. A declined navigation resolves to
* `null`; a failing one also warns.
*/ */
private resolveAction(): Promise<OriginAction | null | undefined> { private resolveAction(
definition: OriginGestureDefinition,
): Promise<OriginAction | null> {
const fail = (error: unknown) => {
warn("A gesture's navigate() factory failed.", error);
return null;
};
try { try {
return Promise.resolve( return Promise.resolve(
this.policy.action(this.origin.context.value), definition.navigation(this.origin.context.value),
).catch(() => null); ).then(
} catch { (navigation) =>
return Promise.resolve(null); navigation
? { ...navigation, choreography: definition.choreography }
: null,
fail,
);
} catch (error) {
return Promise.resolve(fail(error));
}
}
/** Run commit handlers; a failing handler never interrupts the others. */
private static notifyCommit(
definition: OriginGestureDefinition,
context: OriginGestureCompletionContext,
) {
for (const handler of definition.commitHandlers ?? []) {
try {
handler(context);
} catch (error) {
queueMicrotask(() => {
throw error;
});
}
} }
} }
} }
// Composable ---------------------------------------------------------------- // Composables ---------------------------------------------------------------
/**
* Bind any number of definitions to one host. Recognizers are kept per list
* position, so re-rendering with new definition objects never interrupts a
* gesture in progress; each change applies from the next pointer-down.
*/
export const useOriginGestures = (
definitions: MaybeRefOrGetter<readonly OriginGestureDefinition[]>,
): OriginGestureBinding => {
const origin = useOrigin();
const recognizers: GestureRecognizer[] = [];
/** Grow or shrink the recognizer list to match the current definitions. */
const current = () => {
const count = toValue(definitions).length;
while (recognizers.length < count) {
const index = recognizers.length;
recognizers.push(
new GestureRecognizer(() => toValue(definitions)[index]!, origin),
);
}
for (const removed of recognizers.splice(count)) void removed.abandon();
return recognizers;
};
const style = computed(() => ({
width: "100%",
height: "100%",
// Preserve native scrolling on any axis no gesture claims.
touchAction: touchActionFor(
toValue(definitions).map(({ direction }) => direction),
),
}));
// A recognizer whose component unmounts mid-gesture cancels its operation.
onScopeDispose(() => {
for (const recognizer of recognizers.splice(0)) void recognizer.abandon();
});
return {
get style() {
return style.value;
},
onPointerdown: (event) => {
for (const recognizer of current()) recognizer.pointerDown(event);
},
onPointermove: (event) => {
for (const recognizer of current()) void recognizer.pointerMove(event);
},
onPointerup: (event) => {
for (const recognizer of current()) void recognizer.pointerUp(event);
},
onPointercancel: () => {
for (const recognizer of current()) void recognizer.pointerCancel();
},
onLostpointercapture: (event) => {
for (const recognizer of current())
void recognizer.pointerCaptureLost(event);
},
};
};
/** /**
* Install a component-owned pointer recognizer. * Install a component-owned pointer recognizer.
@@ -579,14 +594,14 @@ export class GestureRecognizer {
* *
* Builder definitions may independently describe their pointer-down region * Builder definitions may independently describe their pointer-down region
* and movement direction. Omitting `.from` recognizes pointer-down across the * and movement direction. Omitting `.from` recognizes pointer-down across the
* whole element. The legacy options object remains supported; its `edge` is * whole element.
* inferred from the opposite side of its movement direction.
* *
* Interactive controls and anything inside * Interactive controls and anything inside
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer * `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
* preserves native scrolling on the cross-axis through its returned style. * preserves native scrolling on the cross-axis through its returned style.
* *
* @param definition - An immutable builder result or legacy recognizer options. * @param definition - An immutable builder result, or a ref or getter
* returning one. A changed definition applies from the next pointer-down.
* @returns Pointer handlers and required host styles. * @returns Pointer handlers and required host styles.
* @throws If called outside a component rendered by `OriginScene`. * @throws If called outside a component rendered by `OriginScene`.
* *
@@ -602,21 +617,7 @@ export class GestureRecognizer {
* ``` * ```
*/ */
export function useOriginGesture( export function useOriginGesture(
definition: OriginGestureDefinition | OriginGestureOptions, definition: MaybeRefOrGetter<OriginGestureDefinition>,
): OriginGestureBinding { ): OriginGestureBinding {
const recognizer = GestureRecognizer.create(definition, useOrigin()); return useOriginGestures(() => [toValue(definition)]);
return {
style: {
// Preserve native scrolling perpendicular to the declared gesture.
touchAction: touchActionFor([recognizer.policy.direction]),
// OriginGesture is commonly the root returned by a view component.
width: "100%",
height: "100%",
},
onPointerdown: (event) => recognizer.pointerDown(event),
onPointermove: (event) => void recognizer.pointerMove(event),
onPointerup: (event) => void recognizer.pointerUp(event),
onPointercancel: () => void recognizer.pointerCancel(),
};
} }
+1
View File
@@ -50,6 +50,7 @@ export {
export type * from "./scene/types"; export type * from "./scene/types";
export type { OriginNodeScope, UseOrigin } from "./scene/scene"; export type { OriginNodeScope, UseOrigin } from "./scene/scene";
export { OriginSceneError, type OriginSceneErrorCode } from "./scene/graph";
export { export {
createOriginScene, createOriginScene,
originNodeScopeKey, originNodeScopeKey,
-10
View File
@@ -147,16 +147,6 @@ export interface OriginAction {
readonly history?: OriginHistoryMode; readonly history?: OriginHistoryMode;
} }
/**
* Values accepted from gesture action factories.
*
* Returning `null` or `undefined` declines the recognized gesture. A promise
* allows lazy target resolution; stale results are discarded after release or
* cancellation.
*/
export type MaybeOriginAction =
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
/** Options accepted by {@link originAction}. */ /** Options accepted by {@link originAction}. */
export interface OriginActionOptions { export interface OriginActionOptions {
/** /**
+555
View File
@@ -0,0 +1,555 @@
/**
* Property tests: random operation sequences and pointer streams must keep
* the scene's structural invariants after every step, and must never leave
* an operation stuck once all pointers are released.
*/
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import {
above,
back,
createOriginScene,
defineOriginChoreography,
fade,
forward,
gesture,
OriginGestureSurface,
OriginScene,
OriginSceneError,
originView,
replace,
slideLeft,
slideRight,
under,
type OriginAction,
type OriginOperationHandle,
} from ".";
import type { SceneGraph } from "./scene/graph";
import type { OriginScene as Scene } from "./scene/types";
const SEEDS = Number(process.env.PROPERTY_SEEDS ?? 60);
// Support -------------------------------------------------------------------
function rng(seed: number) {
let a = seed >>> 0;
const next = () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
next,
int: (n: number) => Math.floor(next() * n),
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
chance: (p: number) => next() < p,
};
}
/** Deterministic `requestAnimationFrame` and clock. */
function installFrames() {
let now = 1000;
let queue: FrameRequestCallback[] = [];
window.requestAnimationFrame = ((cb: FrameRequestCallback) =>
queue.push(cb)) as typeof window.requestAnimationFrame;
performance.now = () => now;
return {
advance(ms = 16) {
now += ms;
const run = queue;
queue = [];
for (const cb of run) cb(now);
},
drain() {
for (let i = 0; i < 1000 && queue.length; i += 1) this.advance();
},
};
}
async function flush(rounds = 4) {
for (let i = 0; i < rounds; i += 1)
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
const component = (name: string): Component =>
defineComponent({ name, render: () => h("div", name) });
/** One-line summary of a scene, for failure traces. */
const describeScene = (scene: Scene) => {
const nodes = scene.nodes.value
.map(
(n) =>
`${n.key}:${n.state}:in=${n.incomingOperationId ?? "-"}:prev=${n.previousNodeKey ?? "-"}`,
)
.join(" ");
const operations = scene.operations.value
.map(
(o) =>
`#${o.id} ${o.originKey}→${o.targetKey} ${o.history} ${o.phase}/${o.intent}`,
)
.join(", ");
return ` roots=[${scene.roots.value}] nodes=[${nodes}] ops=[${operations}]`;
};
const graphOf = (scene: Scene) =>
(scene as unknown as { graph: SceneGraph }).graph;
/** Structural invariants that must hold after every step. */
function checkInvariants(scene: Scene, label: string, rootCount: number) {
const graph = graphOf(scene);
const nodes = [...graph.nodes.values()];
const roots = scene.roots.value;
const fail = (message: string) => {
throw new Error(`${label}\n${describeScene(scene)}\n${message}`);
};
if (roots.length !== rootCount)
fail(`expected ${rootCount} roots, found ${roots.length}`);
if (new Set(roots).size !== roots.length) fail("duplicate roots");
for (const key of roots) {
const node = graph.node(key);
if (!node) fail(`root ${key} does not exist`);
else if (graph.incomingOf(node)) fail(`root ${key} has an incoming edge`);
}
for (const node of nodes) {
// Incoming edges point back at their node.
if (node.incomingOperationId !== undefined) {
const incoming = graph.operation(node.incomingOperationId);
if (!incoming) fail(`${node.key} has a dangling incoming edge`);
else if (incoming.targetKey !== node.key)
fail(`${node.key}'s incoming edge targets ${incoming.targetKey}`);
}
// History links exist and are acyclic.
const seen = new Set([node.key]);
for (let key = node.previousNodeKey; key;) {
const previous = graph.node(key);
if (!previous) fail(`${node.key} has a missing previous entry ${key}`);
if (seen.has(key)) fail(`${node.key} has cyclic history`);
seen.add(key);
key = previous?.previousNodeKey;
}
if (node.state === "parked") {
if (roots.includes(node.key)) fail(`parked ${node.key} is a root`);
if (node.incomingOperationId !== undefined)
fail(`parked ${node.key} is positioned by an edge`);
if (scene.isNodeInteractive(node.key)) fail(`parked ${node.key} is live`);
if (scene.styleForNode(node.key).visibility !== "hidden")
fail(`parked ${node.key} is visible`);
continue;
}
// Every presented node reaches a root through acyclic incoming edges.
const visited = new Set<string>();
let current = node;
for (;;) {
if (visited.has(current.key)) fail(`${node.key} is in a visual cycle`);
visited.add(current.key);
const incoming = graph.incomingOf(current);
if (!incoming) break;
const origin = graph.node(incoming.originKey);
if (!origin) fail(`${current.key}'s edge comes from a missing origin`);
current = origin!;
}
if (!roots.includes(current.key))
fail(`${node.key} is detached from every root`);
if (node.state === "exposed" && scene.isNodeInteractive(node.key))
fail(`exposed ${node.key} is live`);
}
// Every live operation joins existing nodes; one per origin.
const origins = new Set<string>();
for (const operation of scene.operations.value) {
if (!graph.node(operation.originKey))
fail(`operation ${operation.id} has a missing origin`);
if (!graph.node(operation.targetKey))
fail(`operation ${operation.id} has a missing target`);
if (origins.has(operation.originKey))
fail(`${operation.originKey} has two live operations`);
origins.add(operation.originKey);
}
}
/** Additional invariants once every operation has finished. */
function checkResting(scene: Scene, rootCount: number, label: string) {
checkInvariants(scene, label, rootCount);
const states = scene.nodes.value.map((node) => node.state);
expect(scene.operations.value, `${label}: live operations`).toEqual([]);
expect(states, `${label}: states`).not.toContain("transitioning");
expect(
states.filter((state) => state === "active"),
`${label}: one active entry per root`,
).toHaveLength(rootCount);
}
// Scene operations ----------------------------------------------------------
const drawerOpen = defineOriginChoreography({
name: "drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 66}%)` },
target: { transform: `translateX(${(progress - 1) * 66}%)` },
}),
});
const drawerClose = defineOriginChoreography({
name: "drawer-close",
commitThreshold: 0.2,
commitVelocity: 0.5,
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 66}%)` },
}),
});
const choreographies = [slideLeft, slideRight, fade, drawerOpen, drawerClose];
const views = ["A", "B", "C", "D"].map((name) =>
originView(component(name), undefined, { key: name.toLowerCase() }),
);
function randomAction(r: ReturnType<typeof rng>): OriginAction {
const choreography = r.pick(choreographies);
const target = r.pick(views);
const placement = r.pick(["above", "under", undefined] as const);
switch (r.int(6)) {
case 0:
return forward(target, choreography, { placement });
case 1:
return replace(target, choreography, { placement });
case 2:
case 3:
return back(choreography, { placement });
case 4:
return above(target, choreography, {
history: r.pick(["push", "replace", "back"] as const),
});
default:
return under(target, choreography);
}
}
async function runOperations(seed: number) {
const r = rng(seed);
const frames = installFrames();
const initial = seed % 3 === 0 ? [views[0]!, views[1]!] : [views[0]!];
const scene = createOriginScene({ initial });
const handles: OriginOperationHandle[] = [];
const unexpected: unknown[] = [];
const expectSceneError = (error: unknown) => {
if (!(error instanceof OriginSceneError)) unexpected.push(error);
};
const trace: string[] = [];
for (let step = 0; step < 80; step += 1) {
const label = () =>
`seed ${seed} step ${step}\n${trace.slice(-60).join("\n")}`;
const roll = r.next();
const liveIds = new Set(scene.operations.value.map(({ id }) => id));
const live = handles.filter(({ id }) => liveIds.has(id));
const pick = () =>
live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
if (roll < 0.3 || handles.length === 0) {
const originKey = r.pick(scene.nodes.value).key;
const action = randomAction(r);
scene.begin(originKey, action).then(
(handle) => {
handles.push(handle);
trace.push(
` begin #${handle.id} ${originKey}→${handle.targetKey} ${action.history} ${action.choreography.name}`,
);
},
(error) => {
trace.push(
` begin ${originKey} ${action.history} rejected: ${error.code}`,
);
expectSceneError(error);
},
);
} else if (roll < 0.55) {
const handle = pick();
const p = r.next() * 1.4 - 0.2;
const v = r.next() * 3 - 1;
trace.push(`update #${handle.id} ${p.toFixed(2)} ${v.toFixed(2)}`);
handle.update(p, v);
} else if (roll < 0.75) {
const handle = pick();
const options = {
commit: r.pick([true, false, undefined]),
animate: r.chance(0.5),
};
trace.push(`finish #${handle.id} ${JSON.stringify(options)}`);
void handle
.finish(options)
.then((v) => trace.push(` finished #${handle.id} → ${v}`));
} else if (roll < 0.82) {
const handle = pick();
trace.push(`cancel #${handle.id}`);
void handle
.cancel({ animate: r.chance(0.5) })
.then(() => trace.push(` cancelled #${handle.id}`));
} else {
trace.push("frames");
for (let i = 1 + r.int(12); i > 0; i -= 1) frames.advance(4 + r.int(40));
}
if (!r.chance(0.15)) await flush();
trace.push(describeScene(scene));
checkInvariants(scene, label(), initial.length);
}
// Release everything: every live operation finishes, then springs drain.
await flush();
for (const handle of handles) void handle.finish({ animate: r.chance(0.5) });
await flush();
frames.drain();
await flush();
checkResting(
scene,
initial.length,
`seed ${seed} at rest\n${trace.slice(-50).join("\n")}`,
);
expect(unexpected, `seed ${seed}: non-scene errors`).toEqual([]);
}
describe("scene properties", () => {
for (let seed = 1; seed <= SEEDS; seed += 1)
it(`random operations ${seed}`, () => runOperations(seed));
});
// Gestures ------------------------------------------------------------------
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
const WIDTH = 300;
const HEIGHT = 600;
function stubBounds(element: HTMLElement) {
if (element.dataset.stubbed) return;
element.dataset.stubbed = "1";
Object.defineProperties(element, {
clientWidth: { configurable: true, value: WIDTH },
clientHeight: { configurable: true, value: HEIGHT },
});
element.getBoundingClientRect = () =>
({ top: 0, left: 0, width: WIDTH, height: HEIGHT }) as DOMRect;
}
/** Views whose gestures can grow, collapse, and race chains of operations. */
function gestureWorld() {
const viewsByName: Record<string, ReturnType<typeof originView>> = {};
const slide = defineOriginChoreography({
name: "slide",
commitVelocity: 0.6,
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 100}px)` },
target: { transform: `translateX(${(1 - progress) * 100}px)` },
}),
});
const surface = (name: string, gestures: () => unknown[]) =>
defineComponent({
name,
setup: () => () =>
h(
OriginGestureSurface,
{ gestures: gestures() as never, "data-gesture-host": "" },
() => [h("input", { "data-input": "" }), name],
),
});
const A = surface("A", () => [
gesture.to
.left()
.navigate(async () => {
await Promise.resolve();
return forward(viewsByName.B!);
})
.animate(slide),
gesture.from
.left(40)
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight),
gesture.from
.when(({ point }) => point.localY < HEIGHT / 2)
.to.down({ threshold: 12 })
.complete(({ progress }) => progress > 0.2)
.navigate(() => forward(viewsByName.C!, { placement: "under" }))
.animate(drawerOpen),
gesture.from
.bottom("20%")
.to.up()
.navigate((context) =>
context.canGoBack ? back() : replace(viewsByName.A!),
)
.animate(fade),
]);
const B = surface("B", () => [
gesture.to
.left({ threshold: 6 })
.navigate(() => forward(viewsByName.C!))
.animate(slide),
gesture.to
.right({ threshold: 6 })
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight),
]);
const C = surface("C", () => [
gesture.to
.up({ threshold: 4 })
.navigate((context) =>
context.canGoBack ? back() : forward(viewsByName.B!),
)
.animate(drawerClose),
gesture.to
.left()
.navigate(() => {
throw new Error("intentional navigate failure");
})
.animate(slide),
]);
viewsByName.A = originView(A, undefined, { key: "a" });
viewsByName.B = originView(B, undefined, { key: "b" });
viewsByName.C = originView(C, undefined, { key: "c" });
const scene = createOriginScene({ initial: viewsByName.A });
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(root);
return { scene, root };
}
function pointerEvent(type: string, init: PointerEventInit, timeStamp: number) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
...init,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
async function runPointers(seed: number) {
const r = rng(seed * 7919);
const frames = installFrames();
const warn = console.warn;
console.warn = () => {};
try {
const { scene, root } = gestureWorld();
await nextTick();
let time = 100;
let x = 0;
let y = 0;
let pointerId = 1;
let host: HTMLElement | null = null;
let heading: "left" | "right" | "up" | "down" = "left";
const trace: string[] = [];
const dispatch = (
type: string,
init: PointerEventInit,
onInput = false,
) => {
trace.push(
`${type} ${host?.closest("[data-origin-node]")?.getAttribute("data-origin-node")} ${JSON.stringify(init)} t=${time}${host?.isConnected ? "" : " (detached)"}`,
);
if (!host?.isConnected) return;
const target = onInput
? (host.querySelector("[data-input]") as HTMLElement)
: host;
target.dispatchEvent(pointerEvent(type, init, time));
};
for (let step = 0; step < 120; step += 1) {
time += r.int(40);
const roll = r.next();
if (roll < 0.15) {
// A new primary pointer means the previous one ended: a browser
// delivers its pointer-up to the captured host, or, if that was
// lost, fires lostpointercapture there.
dispatch(r.chance(0.8) ? "pointerup" : "lostpointercapture", {
clientX: x,
clientY: y,
pointerId,
});
if (r.chance(0.8)) await flush(3);
const nodes = scene.nodes.value.filter((node) =>
scene.isNodeInteractive(node.key),
);
const node = r.pick(nodes.length ? nodes : scene.nodes.value);
host = root.querySelector(
`[data-origin-node="${CSS.escape(node.key)}"] [data-gesture-host]`,
);
if (host) stubBounds(host);
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
pointerId = r.chance(0.8) ? 1 : 2;
heading = r.pick(["left", "right", "up", "down"] as const);
dispatch(
"pointerdown",
{
clientX: x,
clientY: y,
pointerId,
button: r.chance(0.95) ? 0 : 1,
isPrimary: r.chance(0.95),
},
r.chance(0.08),
);
} else if (roll < 0.65) {
const amount = 5 + r.int(55);
if (heading === "left") x -= amount;
if (heading === "right") x += amount;
if (heading === "up") y -= amount;
if (heading === "down") y += amount;
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
} else if (roll < 0.8) {
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
} else if (roll < 0.85) {
dispatch(r.pick(["pointercancel", "lostpointercapture"]), {
pointerId,
});
} else {
trace.push("frames");
for (let i = 1 + r.int(20); i > 0; i -= 1) frames.advance(16);
}
if (r.chance(0.7)) {
frames.advance(16);
frames.advance(16);
}
if (!r.chance(0.2)) await flush(3);
trace.push(describeScene(scene));
checkInvariants(
scene,
`seed ${seed} step ${step}\n${trace.slice(-40).join("\n")}`,
1,
);
}
// Lift the pointer; nothing may remain stuck mid-transition.
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
await flush();
frames.drain();
await flush();
trace.push(describeScene(scene));
checkResting(
scene,
1,
`seed ${seed} after release\n${trace.slice(-40).join("\n")}`,
);
} finally {
console.warn = warn;
}
}
describe("gesture properties", () => {
for (let seed = 1; seed <= SEEDS; seed += 1)
it(`random pointer stream ${seed}`, () => runPointers(seed));
});
+324
View File
@@ -0,0 +1,324 @@
import {
createApp,
defineComponent,
h,
nextTick,
shallowRef,
type Component,
} from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
back,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
OriginGesture,
OriginGestureSurface,
OriginScene,
OriginSceneError,
originView,
type OriginGestureDefinition,
} from ".";
import { shouldCommit } from "./scene/graph";
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const motion = defineOriginChoreography({
name: "recognizer-test",
effects: ({ progress }) => ({ target: { opacity: progress } }),
});
const Target: Component = defineComponent({ render: () => h("div", "T") });
const toTarget = () => forward(originView(Target));
function pointer(
type: string,
clientX: number,
timeStamp: number,
pointerId = 1,
) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId,
clientX,
clientY: 100,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
async function flush() {
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
}
function stubBounds(host: HTMLElement) {
Object.defineProperty(host, "clientWidth", { value: 200 });
host.getBoundingClientRect = () =>
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
}
/** Render `root` as the scene's initial view; return the scene and host. */
async function mount(root: Component) {
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const scene = createOriginScene({ initial: originView(root) });
const element = document.createElement("div");
document.body.append(element);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(element);
await nextTick();
const host = element.querySelector("#host") as HTMLElement;
stubBounds(host);
return { scene, host, app };
}
const gestureHost = (definition: () => OriginGestureDefinition) =>
defineComponent({
setup: () => () =>
h(OriginGesture, { id: "host", gesture: definition() }, () => "host"),
});
/** Press, then drag left far enough to capture and begin an operation. */
async function dragLeft(host: HTMLElement, pointerId = 1, x = 180) {
host.dispatchEvent(pointer("pointerdown", x, 0, pointerId));
host.dispatchEvent(pointer("pointermove", x - 60, 100, pointerId));
await flush();
}
describe("stuck gestures", () => {
const definition = gesture.to.left().navigate(toTarget).animate(motion);
it("cancels a captured gesture when pointer capture is lost", async () => {
const { scene, host } = await mount(gestureHost(() => definition));
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
host.dispatchEvent(pointer("lostpointercapture", 120, 120));
await flush();
expect(scene.operations.value).toHaveLength(0);
expect(scene.nodes.value).toHaveLength(1);
});
it("ignores lostpointercapture after a normal release", async () => {
const { scene, host } = await mount(gestureHost(() => definition));
await dragLeft(host);
host.dispatchEvent(pointer("pointermove", 20, 200));
host.dispatchEvent(pointer("pointerup", 20, 200));
host.dispatchEvent(pointer("lostpointercapture", 20, 200));
await flush();
// Committed, not cancelled.
expect(scene.nodes.value.map((node) => node.state)).toEqual([
"parked",
"active",
]);
});
it("cancels a stale gesture when a new primary pointer goes down", async () => {
const { scene, host } = await mount(gestureHost(() => definition));
await dragLeft(host, 1);
expect(scene.operations.value).toHaveLength(1);
// pointer 1's pointer-up was lost; a new touch starts.
host.dispatchEvent(pointer("pointerdown", 150, 300, 2));
await flush();
expect(scene.operations.value).toHaveLength(0);
expect(scene.nodes.value).toHaveLength(1);
});
it("cancels a captured gesture when its component unmounts", async () => {
const show = shallowRef(true);
const Root = defineComponent({
setup: () => () =>
h("div", [
show.value
? h(OriginGesture, { id: "host", gesture: definition }, () => "x")
: null,
]),
});
const { scene, host } = await mount(Root);
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
show.value = false;
await flush();
expect(scene.operations.value).toHaveLength(0);
});
});
describe("failure warnings", () => {
it("warns when navigate() throws, without touching the scene", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const failing = gesture.to
.left()
.navigate(() => {
throw new Error("no route");
})
.animate(motion);
const { scene, host } = await mount(gestureHost(() => failing));
await dragLeft(host);
expect(scene.operations.value).toHaveLength(0);
expect(warn).toHaveBeenCalledWith(
"[native-vue-router] A gesture's navigate() factory failed.",
new Error("no route"),
);
});
it("warns when the scene rejects the operation", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
// The initial view has no previous entry, so back() is rejected.
const invalid = gesture.to
.left()
.navigate(() => back())
.animate(motion);
const { host } = await mount(gestureHost(() => invalid));
await dragLeft(host);
expect(warn).toHaveBeenCalledTimes(1);
const error = warn.mock.calls[0]![1] as OriginSceneError;
expect(error).toBeInstanceOf(OriginSceneError);
expect(error.code).toBe("no-previous-entry");
});
it("stays quiet when two gestures race for the same origin", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const both = [
gesture.to.left({ threshold: 4 }).navigate(toTarget).animate(motion),
gesture.to.left({ threshold: 6 }).navigate(toTarget).animate(motion),
];
const Root = defineComponent({
setup: () => () =>
h(OriginGestureSurface, { id: "host", gestures: both }, () => "x"),
});
const { scene, host } = await mount(Root);
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
expect(warn).not.toHaveBeenCalled();
});
it("declining with null does not warn", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const declined = gesture.to
.left()
.navigate(() => null)
.animate(motion);
const { host } = await mount(gestureHost(() => declined));
await dragLeft(host);
expect(warn).not.toHaveBeenCalled();
});
});
describe("reactive gesture props", () => {
it("applies a new definition from the next pointer-down only", async () => {
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
const rightward = gesture.to.right().navigate(toTarget).animate(motion);
const current = shallowRef(leftward);
const { scene, host } = await mount(gestureHost(() => current.value));
expect(host.style.touchAction).toBe("pan-y");
// Swap mid-gesture: the drag in progress keeps the leftward definition.
host.dispatchEvent(pointer("pointerdown", 180, 0));
current.value = rightward;
await nextTick();
host.dispatchEvent(pointer("pointermove", 120, 100));
await flush();
expect(scene.operations.value).toHaveLength(1);
host.dispatchEvent(pointer("pointerup", 120, 1000));
await flush();
// The next touch uses the rightward definition.
host.dispatchEvent(pointer("pointerdown", 20, 2000));
host.dispatchEvent(pointer("pointermove", 80, 2100));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("keeps a gesture in progress across a parent re-render", async () => {
const tick = shallowRef(0);
// A new but equivalent definition object on every render.
const { scene, host } = await mount(
gestureHost(() => {
void tick.value;
return gesture.to.left().navigate(toTarget).animate(motion);
}),
);
await dragLeft(host);
tick.value += 1;
await nextTick();
host.dispatchEvent(pointer("pointermove", 20, 200));
host.dispatchEvent(pointer("pointerup", 20, 200));
await flush();
expect(scene.nodes.value.map((node) => node.state)).toEqual([
"parked",
"active",
]);
});
it("tracks a surface's changing gesture list and touch-action", async () => {
const list = shallowRef<OriginGestureDefinition[]>([
gesture.to.left().navigate(toTarget).animate(motion),
]);
const Root = defineComponent({
setup: () => () =>
h(
OriginGestureSurface,
{ id: "host", gestures: list.value },
() => "x",
),
});
const { scene, host } = await mount(Root);
expect(host.style.touchAction).toBe("pan-y");
list.value = [
...list.value,
gesture.to.down().navigate(toTarget).animate(motion),
];
await nextTick();
expect(host.style.touchAction).toBe("none");
host.dispatchEvent(
Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }),
);
const down = new PointerEvent("pointermove", {
bubbles: true,
isPrimary: true,
pointerId: 1,
clientX: 100,
clientY: 80,
});
host.dispatchEvent(down);
await flush();
expect(scene.operations.value).toHaveLength(1);
});
});
describe("commitVelocity", () => {
const operation = (commitVelocity: number | undefined, velocity: number) =>
({
progress: 0.1,
velocity,
choreography: { effects: () => ({}), commitVelocity },
}) as unknown as Parameters<typeof shouldCommit>[0];
it("defaults to 0.3", () => {
expect(shouldCommit(operation(undefined, 0.29), {})).toBe(false);
expect(shouldCommit(operation(undefined, 0.3), {})).toBe(true);
});
it("uses the choreography's value when supplied", () => {
expect(shouldCommit(operation(0.6, 0.5), {})).toBe(false);
expect(shouldCommit(operation(0.6, 0.6), {})).toBe(true);
expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true);
});
it("still yields to an explicit decision", () => {
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
});
});
+143 -30
View File
@@ -24,6 +24,43 @@ export interface SceneNodeRecord {
incomingOperationId?: number; incomingOperationId?: number;
} }
/**
* Why a scene operation was rejected.
*
* - `missing-node`: the node no longer exists.
* - `missing-origin`: the origin node does not exist.
* - `inert-origin`: the origin is parked or exposed and cannot originate.
* - `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 retained back target is transitioning, or still
* positions the origin.
* - `outside-scene`: `useOrigin()` was called outside an `<OriginScene>` view.
*/
export type OriginSceneErrorCode =
| "missing-node"
| "missing-origin"
| "inert-origin"
| "origin-busy"
| "invalid-action"
| "no-previous-entry"
| "undecided-forward"
| "target-busy"
| "outside-scene";
/** Error thrown or rejected by scene operations, with a stable `code`. */
export class OriginSceneError extends Error {
override readonly name = "OriginSceneError";
constructor(
readonly code: OriginSceneErrorCode,
message: string,
) {
super(message);
}
}
/** /**
* Progress at which a release commits when the choreography does not supply * Progress at which a release commits when the choreography does not supply
* its own `commitThreshold`. * its own `commitThreshold`.
@@ -32,9 +69,9 @@ export const DEFAULT_COMMIT_THRESHOLD = 0.36;
/** /**
* Release velocity (progress units per second) that commits a flick at any * Release velocity (progress units per second) that commits a flick at any
* progress. Choreographies' `commitVelocity` is not currently consulted. * progress when the choreography does not supply its own `commitVelocity`.
*/ */
export const FLICK_COMMIT_VELOCITY = 0.3; 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 progress/velocity. */
export const shouldCommit = ( export const shouldCommit = (
@@ -42,11 +79,10 @@ export const shouldCommit = (
options: OriginFinishOptions, options: OriginFinishOptions,
): boolean => { ): boolean => {
if (options.commit !== undefined) return options.commit; if (options.commit !== undefined) return options.commit;
const threshold = const { commitThreshold, commitVelocity } = operation.choreography;
operation.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD;
return ( return (
operation.progress >= threshold || operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD) ||
(operation.progress >= 0 && operation.velocity >= FLICK_COMMIT_VELOCITY) operation.velocity >= (commitVelocity ?? DEFAULT_COMMIT_VELOCITY)
); );
}; };
@@ -156,7 +192,10 @@ export class SceneGraph {
contextFor(nodeKey: string): OriginContext { contextFor(nodeKey: string): OriginContext {
const node = this.nodes.get(nodeKey); const node = this.nodes.get(nodeKey);
if (!node) if (!node)
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`); throw new OriginSceneError(
"missing-node",
`Origin scene node "${nodeKey}" no longer exists.`,
);
const history = this.historyOf(node).map((entry) => entry.view); const history = this.historyOf(node).map((entry) => entry.view);
return { return {
nodeKey, nodeKey,
@@ -199,7 +238,8 @@ export class SceneGraph {
const origin = this.assertCanOriginate(originKey); const origin = this.assertCanOriginate(originKey);
const history = action.history ?? "push"; const history = action.history ?? "push";
if (action.choreography.persistAtRest && history !== "push") if (action.choreography.persistAtRest && history !== "push")
throw new Error( throw new OriginSceneError(
"invalid-action",
"persistAtRest choreographies require retained-history push navigation.", "persistAtRest choreographies require retained-history push navigation.",
); );
@@ -270,7 +310,11 @@ export class SceneGraph {
origin.state = "parked"; origin.state = "parked";
} else { } else {
// Back pops the current entry; replace discards it in favor of the new // Back pops the current entry; replace discards it in favor of the new
// target. Both preserve every earlier retained instance. // target. Both preserve every earlier retained instance, and any entry
// still retained above the origin now sits on the target instead.
for (const node of this.nodes.values())
if (node.previousNodeKey === origin.key && node !== target)
node.previousNodeKey = target.key;
this.removeNode(origin.key); this.removeNode(origin.key);
} }
@@ -281,33 +325,37 @@ export class SceneGraph {
/** /**
* Finalize a cancelled operation, restoring the origin. * Finalize a cancelled operation, restoring the origin.
* *
* A cancelled back re-parks its retained target, or re-attaches the resting * Everything the edge created or now positions is undone. Entries from the
* edge it suspended. A cancelled push/replace removes its new target branch. * origin's retained history (a back target, or a replacement of one) return
* to history; anything newer is discarded. A cancelled back that suspended
* a connected resting edge reconnects it instead.
*/ */
cancel(id: number): void { cancel(id: number): void {
const operation = this.operations.get(id); const operation = this.operations.get(id);
if (!operation) return; if (!operation) return;
const origin = this.nodes.get(operation.originKey); const origin = this.nodes.get(operation.originKey);
const target = this.nodes.get(operation.entryTargetKey); const entry = this.nodes.get(operation.entryTargetKey);
const current = this.nodes.get(operation.targetKey);
const suspendedEdge = this.suspendedEdges.get(id); const suspendedEdge = this.suspendedEdges.get(id);
const retained = origin ? this.retainedKeys(origin) : new Set<string>();
operation.phase = "finished"; operation.phase = "finished";
this.detach(operation); this.detach(operation);
this.suspendedEdges.delete(id); this.suspendedEdges.delete(id);
if (operation.history === "back") { if (suspendedEdge && origin && entry && current === entry) {
if (suspendedEdge && origin && target) {
// Put the retained page back into its former position, then reconnect // Put the retained page back into its former position, then reconnect
// the resting page→drawer edge. Both nodes keep their VNodes. // the resting page→drawer edge. Both nodes keep their VNodes.
this.transferPosition(origin, target); this.discardOutgoing(entry.key, new Set(retained));
this.transferPosition(origin, entry);
this.attach(suspendedEdge); this.attach(suspendedEdge);
origin.incomingOperationId = suspendedEdge.id; origin.incomingOperationId = suspendedEdge.id;
target.state = "exposed"; entry.state = "exposed";
} else if (target) {
target.incomingOperationId = undefined;
target.state = "parked";
}
} else { } else {
this.removeBranch(operation.entryTargetKey); for (const node of new Set([entry, current])) {
if (!node || node === origin) continue;
if (retained.has(node.key)) this.returnToHistory(node, retained);
else this.removeBranch(node.key, new Set(retained));
}
} }
if (origin) this.activate(origin); if (origin) this.activate(origin);
@@ -318,13 +366,18 @@ export class SceneGraph {
private assertCanOriginate(originKey: string): SceneNodeRecord { private assertCanOriginate(originKey: string): SceneNodeRecord {
const origin = this.nodes.get(originKey); const origin = this.nodes.get(originKey);
if (!origin) if (!origin)
throw new Error(`Cannot animate from missing origin "${originKey}".`); throw new OriginSceneError(
"missing-origin",
`Cannot animate from missing origin "${originKey}".`,
);
if (origin.state === "parked" || origin.state === "exposed") if (origin.state === "parked" || origin.state === "exposed")
throw new Error( throw new OriginSceneError(
"inert-origin",
`Cannot animate from ${origin.state} origin "${originKey}".`, `Cannot animate from ${origin.state} origin "${originKey}".`,
); );
if (this.liveOutgoingOf(originKey)) if (this.liveOutgoingOf(originKey))
throw new Error( throw new OriginSceneError(
"origin-busy",
`Origin "${originKey}" already has an outgoing operation. ` + `Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.", "A descendant target may start its own operation instead.",
); );
@@ -346,12 +399,14 @@ export class SceneGraph {
} { } {
const previousKey = origin.previousNodeKey; const previousKey = origin.previousNodeKey;
if (!previousKey) if (!previousKey)
throw new Error( throw new OriginSceneError(
"no-previous-entry",
`Cannot go back from origin "${origin.key}" without a previous entry.`, `Cannot go back from origin "${origin.key}" without a previous entry.`,
); );
const target = this.nodes.get(previousKey); const target = this.nodes.get(previousKey);
if (!target) if (!target)
throw new Error( throw new OriginSceneError(
"no-previous-entry",
`Cannot go back to missing retained entry "${previousKey}".`, `Cannot go back to missing retained entry "${previousKey}".`,
); );
@@ -359,7 +414,8 @@ export class SceneGraph {
const incoming = this.incomingOf(origin); const incoming = this.incomingOf(origin);
if (incoming?.originKey === target.key) { if (incoming?.originKey === target.key) {
if (incoming.intent !== "commit") if (incoming.intent !== "commit")
throw new Error( throw new OriginSceneError(
"undecided-forward",
"Cannot go back through an undecided forward operation.", "Cannot go back through an undecided forward operation.",
); );
if (incoming.phase !== "finished") this.commit(incoming.id); if (incoming.phase !== "finished") this.commit(incoming.id);
@@ -374,12 +430,25 @@ export class SceneGraph {
} }
} }
// A target still positioning the origin (through edges other than the
// one collapsed above) would close a visual cycle.
if (
this.incomingChain(origin).some(
(operation) => operation.originKey === target.key,
)
)
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" still positions origin "${origin.key}".`,
);
if (this.liveOutgoingOf(target.key)) if (this.liveOutgoingOf(target.key))
throw new Error( throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" already has an outgoing operation.`, `Retained target "${target.key}" already has an outgoing operation.`,
); );
if (target.incomingOperationId) if (target.incomingOperationId)
throw new Error( throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" is already transitioning.`, `Retained target "${target.key}" is already transitioning.`,
); );
return { target, suspendedEdge }; return { target, suspendedEdge };
@@ -392,7 +461,8 @@ export class SceneGraph {
history: Exclude<OriginHistoryMode, "back">, history: Exclude<OriginHistoryMode, "back">,
): SceneNodeRecord { ): SceneNodeRecord {
if (!action.target) if (!action.target)
throw new Error( throw new OriginSceneError(
"invalid-action",
`A "${history}" operation requires a target view recipe.`, `A "${history}" operation requires a target view recipe.`,
); );
// A replacement skips the origin in the retained chain. The origin stays // A replacement skips the origin in the retained chain. The origin stays
@@ -402,7 +472,16 @@ export class SceneGraph {
return this.createNode(action.target, previousKey); return this.createNode(action.target, previousKey);
} }
/**
* Make a node presentable again. A node still holding a connected resting
* edge (an open drawer, say) stays exposed rather than becoming active.
*/
private activate(node: SceneNodeRecord): void { private activate(node: SceneNodeRecord): void {
const resting = this.presentedOutgoingOf(node.key);
if (resting?.phase === "finished") {
node.state = "exposed";
return;
}
node.state = "active"; node.state = "active";
this.refreshPresentedState(node); this.refreshPresentedState(node);
} }
@@ -490,9 +569,42 @@ export class SceneGraph {
this.roots.value = this.roots.value.filter((key) => key !== nodeKey); this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
} }
/**
* Resolve the live operations a node started, because the node is leaving
* the visible graph: nodes they created (push/replace targets, including
* replacements whose history skips this node) are removed, and retained
* entries they revealed (back targets) are restored as if that back
* operation were cancelled.
*/
/** Park a retained entry again, discarding what it started meanwhile. */
private returnToHistory(node: SceneNodeRecord, retained: Set<string>): void {
this.discardOutgoing(node.key, new Set(retained));
node.incomingOperationId = undefined;
node.state = "parked";
}
/** A node's key plus its retained history, which a cancel must keep. */
private retainedKeys(node: SceneNodeRecord): Set<string> {
return new Set([node.key, ...this.historyOf(node).map(({ key }) => key)]);
}
private discardOutgoing(nodeKey: string, visited = new Set<string>()): void {
for (const operation of [...(this.outgoing.get(nodeKey) ?? [])]) {
if (operation.phase === "finished") continue;
if (operation.history === "back") {
this.cancel(operation.id);
} else {
this.detach(operation);
this.removeBranch(operation.entryTargetKey, visited);
this.removeBranch(operation.targetKey, visited);
}
}
}
/** /**
* Remove a node, every node whose retained history passes through it, and * Remove a node, every node whose retained history passes through it, and
* every operation touching any of them. Earlier entries are untouched. * every operation touching any of them. Earlier entries are untouched.
* Operations the node itself started are discarded first.
*/ */
private removeBranch(nodeKey: string, visited = new Set<string>()): void { private removeBranch(nodeKey: string, visited = new Set<string>()): void {
if (visited.has(nodeKey)) return; if (visited.has(nodeKey)) return;
@@ -501,6 +613,7 @@ export class SceneGraph {
for (const node of [...this.nodes.values()]) for (const node of [...this.nodes.values()])
if (node.previousNodeKey === nodeKey) if (node.previousNodeKey === nodeKey)
this.removeBranch(node.key, visited); this.removeBranch(node.key, visited);
this.discardOutgoing(nodeKey, visited);
for (const operation of [...this.operations.values()]) for (const operation of [...this.operations.values()])
if ( if (
operation.originKey === nodeKey || operation.originKey === nodeKey ||
+87 -18
View File
@@ -14,6 +14,7 @@ import {
type OriginEffect, type OriginEffect,
type OriginEffectSet, type OriginEffectSet,
type OriginOperationIntent, type OriginOperationIntent,
type OriginOperationPhase,
} from "../choreography"; } from "../choreography";
import type { OriginAction, OriginView } from "../navigation"; import type { OriginAction, OriginView } from "../navigation";
import { import {
@@ -24,7 +25,12 @@ import {
windowRect, windowRect,
type OriginRect, type OriginRect,
} from "../shared"; } from "../shared";
import { SceneGraph, shouldCommit, type SceneNodeRecord } from "./graph"; import {
OriginSceneError,
SceneGraph,
shouldCommit,
type SceneNodeRecord,
} from "./graph";
import type { import type {
CreateOriginSceneOptions, CreateOriginSceneOptions,
MutableOriginOperation, MutableOriginOperation,
@@ -277,6 +283,12 @@ class Scene implements OriginScene {
* operation's progress, phase, intent, rects, or the viewport change, so * operation's progress, phase, intent, rects, or the viewport change, so
* nodes sharing an edge do not evaluate its choreography again. * nodes sharing an edge do not evaluate its choreography again.
*/ */
/** The latest release (finish call) of each operation. */
private readonly releases = new WeakMap<MutableOriginOperation, object>();
private readonly outcomes = new WeakMap<
MutableOriginOperation,
{ promise: Promise<boolean>; resolve(committed: boolean): void }
>();
private readonly effectSets = new WeakMap< private readonly effectSets = new WeakMap<
MutableOriginOperation, MutableOriginOperation,
ComputedRef<OriginEffectSet> ComputedRef<OriginEffectSet>
@@ -334,25 +346,73 @@ class Scene implements OriginScene {
/** /**
* Fix an operation's outcome, settle it, and rewrite the graph. * Fix an operation's outcome, settle it, and rewrite the graph.
* *
* Calling `update()` while an operation settles "catches" it: the spring
* stops and progress is under control again. A later `finish()` then
* decides the outcome; every earlier, interrupted `finish()` resolves with
* that final outcome instead of applying its own.
*
* @internal Called through {@link OperationHandle}. * @internal Called through {@link OperationHandle}.
*/ */
async finish(id: number, options: OriginFinishOptions = {}) { finish(id: number, options: OriginFinishOptions = {}): Promise<boolean> {
const operation = this.graph.operation(id); const operation = this.graph.operation(id);
if (!operation) return false; if (!operation) return Promise.resolve(false);
if (operation.phase === "finished") return operation.intent === "commit"; if (operation.phase === "finished")
return Promise.resolve(operation.intent === "commit");
const release = {};
this.releases.set(operation, release);
return this.release(operation, release, options);
}
private async release(
operation: MutableOriginOperation,
release: object,
options: OriginFinishOptions,
): Promise<boolean> {
// The outcome is fixed at release, so the target can originate another // The outcome is fixed at release, so the target can originate another
// operation while this one is only visually settling. // operation while this one is only visually settling.
const commit = shouldCommit(operation, options); const commit = shouldCommit(operation, options);
operation.intent = commit ? "commit" : "cancel"; operation.intent = commit ? "commit" : "cancel";
operation.phase = "settling"; operation.phase = "settling";
await this.settle(id, commit ? 1 : 0, options.animate ?? true); const current = () =>
this.releases.get(operation) === release &&
operation.phase === "settling";
await this.settle(
operation,
commit ? 1 : 0,
options.animate ?? true,
current,
);
const outcome = this.outcomeOf(operation);
// A later operation may already have finalized this edge. // A later operation may already have finalized this edge.
if (!this.graph.operation(id)) return commit; const finished = (operation.phase as OriginOperationPhase) === "finished";
if (commit) return this.graph.commit(id); if (!this.graph.operation(operation.id) || finished) {
this.graph.cancel(id); const committed = this.graph.operation(operation.id)
return false; ? operation.intent === "commit"
: commit;
outcome.resolve(committed);
return committed;
}
// Caught by update() or superseded by a later finish().
if (!current()) return outcome.promise;
const committed = commit
? this.graph.commit(operation.id)
: (this.graph.cancel(operation.id), false);
outcome.resolve(committed);
return committed;
}
/** Resolves with an operation's final outcome, whichever release decides. */
private outcomeOf(operation: MutableOriginOperation) {
let outcome = this.outcomes.get(operation);
if (!outcome) {
let resolve!: (committed: boolean) => void;
const promise = new Promise<boolean>((done) => (resolve = done));
outcome = { promise, resolve };
this.outcomes.set(operation, outcome);
}
return outcome;
} }
registerElement(nodeKey: string, element: HTMLElement | null): void { registerElement(nodeKey: string, element: HTMLElement | null): void {
@@ -381,10 +441,17 @@ class Scene implements OriginScene {
return this.graph.isInteractive(nodeKey); return this.graph.isInteractive(nodeKey);
} }
/** Move an operation to 0 or 1, animated unless motion is disabled. */ /**
private async settle(id: number, target: 0 | 1, animate: boolean) { * Move an operation to 0 or 1, animated unless motion is disabled. The
const operation = this.graph.operation(id); * spring stops early once `current()` reports the release was caught or
if (!operation) return; * superseded.
*/
private async settle(
operation: MutableOriginOperation,
target: 0 | 1,
animate: boolean,
current: () => boolean,
) {
if (!animate || prefersReducedMotion()) { if (!animate || prefersReducedMotion()) {
operation.progress = target; operation.progress = target;
operation.velocity = 0; operation.velocity = 0;
@@ -392,10 +459,9 @@ class Scene implements OriginScene {
} }
const from = { position: operation.progress, velocity: operation.velocity }; const from = { position: operation.progress, velocity: operation.velocity };
await runSpring(from, target, (progress, velocity) => { await runSpring(from, target, (progress, velocity) => {
const live = this.graph.operation(id); if (!this.graph.operation(operation.id) || !current()) return false;
if (!live) return false; operation.progress = progress;
live.progress = progress; operation.velocity = velocity;
live.velocity = velocity;
return true; return true;
}); });
} }
@@ -533,7 +599,10 @@ export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
export function useOrigin(): UseOrigin { export function useOrigin(): UseOrigin {
const scope = inject(originNodeScopeKey); const scope = inject(originNodeScopeKey);
if (!scope) if (!scope)
throw new Error("useOrigin() must be called inside an <OriginScene> view."); throw new OriginSceneError(
"outside-scene",
"useOrigin() must be called inside an <OriginScene> view.",
);
const { scene, nodeKey } = scope; const { scene, nodeKey } = scope;
const context = computed(() => scene.contextFor(nodeKey)); const context = computed(() => scene.contextFor(nodeKey));
+6
View File
@@ -50,3 +50,9 @@ export function prefersReducedMotion(): boolean {
Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
); );
} }
/** Report a recoverable problem to developers without interrupting the UI. */
export function warn(message: string, error?: unknown): void {
if (typeof console !== "undefined")
console.warn(`[native-vue-router] ${message}`, ...(error ? [error] : []));
}
+11 -18
View File
@@ -1,27 +1,20 @@
# Differential verification # Benchmarks
Checks a core-v2 implementation against an earlier one (by default `main`) Compares core-v2's performance against an earlier implementation (by default
by driving both with identical randomized inputs and requiring identical `main`).
observable results after every step.
From the repository root: From the repository root:
```sh ```sh
packages/core-v2/verify/setup-baseline.sh main # extract into verify/.baseline/ packages/core-v2/verify/setup-baseline.sh main # extract into verify/.baseline/
npx vitest run --config packages/core-v2/verify/vitest.config.ts
SEEDS=50 npx vitest run --config packages/core-v2/verify/vitest.config.ts # quicker
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
ONLY=baseline npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts ONLY=baseline npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
``` ```
- `scene.diff.ts` runs random `begin`/`update`/`finish`/`cancel`/animation `scene.bench.ts` measures compositing, push/back cycles, and rendered frames.
frame sequences against both scenes and compares nodes, operations, roots, Run each implementation in its own process with `ONLY=`; running both in one
every node's composited style, interactivity, contexts, and every promise process biases whichever runs second.
result. It also compares action-helper output and the runtime export list.
- `gesture.diff.ts` mounts both packages in Vue apps whose views install the Behavioural verification lives with the unit tests: `src/property.test.ts`
same gestures (builder definitions, legacy options, `OriginGesture`, and drives random operation sequences and pointer streams and checks the scene's
`OriginGestureSurface`), dispatches identical random pointer streams, and structural invariants after every step. Set `PROPERTY_SEEDS` to run more.
compares scene state and every navigation, start, and completion callback.
- `scene.bench.ts` measures compositing, push/back cycles, and rendered
frames. Run each implementation in its own process with `ONLY=`; running
both in one process biases whichever runs second.
-381
View File
@@ -1,381 +0,0 @@
/**
* Differential test for gesture recognition: the baseline and rewritten
* packages each render a scene whose views install equivalent gestures
* (builder, legacy options, OriginGesture, OriginGestureSurface). Identical
* random pointer streams must yield identical scenes and identical callbacks.
*/
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import * as next from "../src";
import * as base from "./.baseline";
import { flush, installFrames, rng, snapshot } from "./harness";
type Impl = typeof next;
const impls = [base as unknown as Impl, next] as const;
const apps: App[] = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
const WIDTH = 300;
const HEIGHT = 600;
function stubBounds(element: HTMLElement) {
if ((element as { stubbed?: boolean }).stubbed) return;
(element as { stubbed?: boolean }).stubbed = true;
Object.defineProperties(element, {
clientWidth: { configurable: true, value: WIDTH },
clientHeight: { configurable: true, value: HEIGHT },
});
element.getBoundingClientRect = () =>
({
top: 0,
left: 0,
right: WIDTH,
bottom: HEIGHT,
width: WIDTH,
height: HEIGHT,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
}
function pointOf(point: next.OriginGesturePoint) {
return [point.clientX, point.clientY, point.localX, point.localY];
}
/** Build one implementation's views, gestures, and callback recorder. */
function buildWorld(M: Impl, seed: number) {
const record: unknown[] = [];
const r = rng(seed);
const tick = async (count: number) => {
for (let i = 0; i < count; i += 1) await Promise.resolve();
};
const views: Record<string, next.OriginView> = {};
const slide = M.defineOriginChoreography({
name: "slide",
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 100}px)` },
target: { transform: `translateX(${(1 - progress) * 100}px)` },
}),
});
const drawer = M.defineOriginChoreography({
name: "drawer",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateY(${progress * 50}%)` },
target: { opacity: progress },
}),
});
// Asynchronous navigation delays are fixed per world so both match.
const delays = Array.from({ length: 64 }, () => r.int(4));
let delayIndex = 0;
const delay = () => tick(delays[delayIndex++ % delays.length]!);
const surfaceGestures = [
M.gesture.to
.left()
.navigate(async (context) => {
record.push(["left", context.nodeKey]);
await delay();
return M.forward(views.B!);
})
.animate(slide),
M.gesture.from
.left(40)
.to.right()
.navigate((context) => {
record.push(["right", context.nodeKey, context.canGoBack]);
return context.canGoBack ? M.back() : null;
})
.animate(M.slideRight),
M.gesture.from
.when(({ point, bounds, origin }) => {
record.push(["when", origin.nodeKey, pointOf(point), bounds.width]);
return point.localY < HEIGHT / 2;
})
.to.down({ threshold: 12, axisDominance: 1.5 })
.complete((context) => {
record.push([
"complete",
context.origin.nodeKey,
context.direction,
context.progress,
context.velocity,
context.distance,
context.crossDistance,
context.duration,
pointOf(context.start),
pointOf(context.current),
context.bounds,
]);
return context.progress > 0.2;
})
.navigate(() => M.forward(views.C!, { placement: "under" }))
.animate(drawer),
M.gesture.from
.bottom(120)
.to.up()
.navigate(async (context) => {
await delay();
if (delays[delayIndex % delays.length] === 0) throw new Error("nav");
return context.canGoBack ? M.back() : M.replace(views.A!);
})
.animate(M.fade),
];
const A = defineComponent({
name: "A",
setup: () => () =>
h(
M.OriginGestureSurface,
{ gestures: surfaceGestures, "data-gesture-host": "" },
() => [h("input", { "data-input": "" }), "A"],
),
});
const bGestures = [
M.gesture.to
.left({ threshold: 6 })
.navigate((context) => {
record.push(["b-left", context.nodeKey, context.history.length]);
return M.forward(views.C!);
})
.animate(slide),
M.gesture.to
.right({ threshold: 6 })
.navigate((context) => (context.canGoBack ? M.back() : null))
.animate(M.slideRight),
];
const B = defineComponent({
name: "B",
setup() {
// Bind each recognizer directly instead of through a component.
const bindings = bGestures.map((definition) =>
M.useOriginGesture(definition),
);
const each =
(name: "onPointerdown" | "onPointermove" | "onPointerup") =>
(event: PointerEvent) =>
bindings.forEach((binding) => binding[name](event));
return () =>
h(
"div",
{
"data-gesture-host": "",
style: bindings[0]!.style,
onPointerdown: each("onPointerdown"),
onPointermove: each("onPointermove"),
onPointerup: each("onPointerup"),
onPointercancel: () =>
bindings.forEach((binding) => binding.onPointercancel()),
},
[h("input", { "data-input": "" }), "B"],
);
},
});
const C = defineComponent({
name: "C",
setup: () => () =>
h(
M.OriginGesture,
{
"data-gesture-host": "",
direction: "up",
edge: 200,
threshold: 4,
action: (context: next.OriginContext) => {
record.push(["legacy", context.nodeKey]);
return context.canGoBack
? M.back(slide)
: M.forward(views.B!, slide);
},
},
() => [h("input", { "data-input": "" }), "C"],
),
});
views.A = M.originView(A, undefined, { key: "a" });
views.B = M.originView(B, undefined, { key: "b" });
views.C = M.originView(C, undefined, { key: "c" });
const scene = M.createOriginScene({ initial: views.A });
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(M.OriginScene, { scene }) });
apps.push(app);
app.mount(root);
return { scene, root, record };
}
function hostFor(root: HTMLElement, nodeKey: string) {
const host = root.querySelector(
`[data-origin-node="${CSS.escape(nodeKey)}"] [data-gesture-host]`,
) as HTMLElement | null;
if (host) stubBounds(host);
return host;
}
function pointerEvent(
type: string,
init: PointerEventInit,
timeStamp: number,
): PointerEvent {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
...init,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
const stats = {
maxNodes: 0,
maxOps: 0,
opSteps: 0,
records: {} as Record<string, number>,
finalNodes: {} as Record<number, number>,
};
async function runSequence(seed: number, steps: number) {
const r = rng(seed * 7919);
const frames = installFrames();
const worlds = impls.map((M) => buildWorld(M, seed));
await nextTick();
let time = 100;
let x = 0;
let y = 0;
let pointerId = 1;
let nodeKey = "";
let heading: next.OriginGestureDirection = "left";
let open = false;
const log: string[] = [];
const check = (label: string) => {
const message = `${label}\n${log.join("\n")}`;
expect(snapshot(worlds[1]!.scene), message).toEqual(
snapshot(worlds[0]!.scene),
);
expect(worlds[1]!.record, message).toEqual(worlds[0]!.record);
};
const dispatch = (type: string, init: PointerEventInit, onInput = false) => {
for (const world of worlds) {
const host = hostFor(world.root, nodeKey);
if (!host) continue;
const target = onInput
? (host.querySelector("[data-input]") as HTMLElement)
: host;
target.dispatchEvent(pointerEvent(type, init, time));
}
};
for (let step = 0; step < steps; step += 1) {
const roll = r.next();
time += r.int(40);
if (roll < 0.15) {
if (open && r.chance(0.8)) {
log.push(`up ${x},${y} t=${time}`);
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
await flush(3);
}
open = true;
const nodes = worlds[0]!.scene.nodes.value;
const scene = worlds[0]!.scene;
const interactive = nodes.filter((node) =>
scene.isNodeInteractive(node.key),
);
nodeKey = (
interactive.length && r.chance(0.85)
? r.pick(interactive)
: r.pick(nodes)
).key;
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
pointerId = r.chance(0.9) ? 1 : 2;
const init = {
clientX: x,
clientY: y,
pointerId,
button: r.chance(0.95) ? 0 : 1,
isPrimary: r.chance(0.95),
};
heading = r.pick(["left", "right", "up", "down"] as const);
log.push(`down ${nodeKey} ${JSON.stringify(init)} t=${time}`);
dispatch("pointerdown", init, r.chance(0.08));
} else if (roll < 0.65) {
const jitter = r.int(9) - 4;
if (r.chance(0.75)) {
const amount = r.chance(0.1) ? 0 : 5 + r.int(55);
if (heading === "left") x -= amount;
if (heading === "right") x += amount;
if (heading === "up") y -= amount;
if (heading === "down") y += amount;
if (heading === "left" || heading === "right") y += jitter;
else x += jitter;
} else {
x += r.int(70) - 35;
y += r.int(70) - 35;
}
log.push(`move ${x},${y} t=${time}`);
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
} else if (roll < 0.85) {
open = false;
log.push(`up ${x},${y} t=${time}`);
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
} else if (roll < 0.9) {
open = false;
log.push(`cancel`);
dispatch("pointercancel", { pointerId });
} else {
const count = 1 + r.int(20);
log.push(`frames ${count}`);
for (let i = 0; i < count; i += 1) frames.advance(16);
}
if (r.chance(0.7)) {
frames.advance(16);
frames.advance(16);
}
if (!r.chance(0.2)) await flush(3);
check(`seed ${seed} step ${step}`);
stats.maxNodes = Math.max(
stats.maxNodes,
worlds[0]!.scene.nodes.value.length,
);
stats.maxOps = Math.max(
stats.maxOps,
worlds[0]!.scene.operations.value.length,
);
stats.opSteps += worlds[0]!.scene.operations.value.length ? 1 : 0;
}
for (const entry of worlds[0]!.record as unknown[][])
stats.records[entry[0] as string] =
(stats.records[entry[0] as string] ?? 0) + 1;
stats.finalNodes[worlds[0]!.scene.nodes.value.length] =
(stats.finalNodes[worlds[0]!.scene.nodes.value.length] ?? 0) + 1;
for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16);
await flush();
check(`seed ${seed} drained`);
return worlds[0]!.scene.nodes.value.length;
}
describe("gesture: baseline vs rewrite", () => {
const seeds = Number(process.env.SEEDS ?? 200);
for (let seed = 1; seed <= seeds; seed += 1) {
it(`random pointer stream ${seed}`, async () => {
await runSequence(seed, 120);
});
}
it("reached multi-node scenes", () => {
console.log("STATS", JSON.stringify(stats));
expect(stats.maxNodes).toBeGreaterThan(2);
});
});
-105
View File
@@ -1,110 +1,5 @@
import { defineComponent, h, type Component } from "vue"; import { defineComponent, h, type Component } from "vue";
import type { OriginScene } from "../src";
export function rng(seed: number) {
let a = seed >>> 0;
const next = () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
next,
int: (n: number) => Math.floor(next() * n),
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
chance: (p: number) => next() < p,
};
}
export function component(name: string): Component { export function component(name: string): Component {
return defineComponent({ name, render: () => h("div", name) }); return defineComponent({ name, render: () => h("div", name) });
} }
/** Deterministic rAF + clock shared by both implementations. */
export function installFrames() {
let now = 1000;
let queue: FrameRequestCallback[] = [];
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
queue.push(cb);
return queue.length;
}) as typeof window.requestAnimationFrame;
globalThis.requestAnimationFrame = window.requestAnimationFrame;
const perf = performance;
perf.now = () => now;
return {
advance(ms = 16) {
now += ms;
const run = queue;
queue = [];
for (const cb of run) cb(now);
},
pending: () => queue.length,
};
}
export async function flush(rounds = 6) {
for (let i = 0; i < rounds; i += 1)
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
/** Implementation-agnostic, comparable snapshot of a scene. */
export function snapshot(scene: OriginScene) {
const nodes = scene.nodes.value;
return {
nodes: nodes.map((node) => ({
key: node.key,
sequence: node.sequence,
view: node.view.key,
previousNodeKey: node.previousNodeKey,
state: node.state,
incomingOperationId: node.incomingOperationId,
history: node.history.map((view) => view.key),
})),
operations: scene.operations.value.map((operation) => ({
id: operation.id,
originKey: operation.originKey,
targetKey: operation.targetKey,
entryTargetKey: operation.entryTargetKey,
choreography: operation.choreography.name,
placement: operation.placement,
history: operation.history,
progress: operation.progress,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
})),
roots: [...scene.roots.value],
styles: Object.fromEntries(
nodes.map((node) => [node.key, scene.styleForNode(node.key)]),
),
interactive: Object.fromEntries(
nodes.map((node) => [node.key, scene.isNodeInteractive(node.key)]),
),
contexts: Object.fromEntries(
nodes.map((node) => {
const context = scene.contextFor(node.key);
return [
node.key,
{
nodeKey: context.nodeKey,
view: context.view.key,
canGoBack: context.canGoBack,
previous: context.previous?.key,
history: context.history.map((view) => view.key),
},
];
}),
),
missingStyle: scene.styleForNode("missing::0"),
missingInteractive: scene.isNodeInteractive("missing::0"),
};
}
export async function settle<T>(promise: Promise<T>) {
return promise.then(
(value) => ({ ok: true as const, value }),
(error: Error) => ({ ok: false as const, error: error.message }),
);
}
-285
View File
@@ -1,285 +0,0 @@
/**
* Differential test: drives the baseline (main) and rewritten scenes with the
* same random operation sequences and requires identical observable state
* after every step.
*/
import { afterAll, describe, expect, it } from "vitest";
import * as next from "../src";
import * as base from "./.baseline";
import {
component,
flush,
installFrames,
rng,
settle,
snapshot,
} from "./harness";
type Impl = typeof next;
const impls = [base as unknown as Impl, next] as const;
const css = (style: Record<string, string>) => style as never;
const choreographies = [
next.defineOriginChoreography({
name: "layered",
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 100}px)` },
target: { transform: `translateX(${(1 - progress) * 100}px)` },
}),
}),
next.defineOriginChoreography({
name: "probe",
commitThreshold: 0.5,
effects: (c) => ({
frame: { style: css({ filter: `blur(${c.progress}px)` }), layer: 1 },
source: {
opacity: 1 - c.progress,
style: css({
opacity: "0.9",
transform: `rotate(${c.velocity}deg)`,
"--state": `${c.phase}/${c.intent}`,
}),
},
target: {
transform: c.progress > 0.5 ? "none" : `scale(${c.progress})`,
layer: 2,
style: css({
borderRadius: `${c.viewport.width}x${c.viewport.height}`,
"--rects": JSON.stringify([
c.originRect ?? null,
c.targetRect ?? null,
]),
position: "fixed",
}),
},
}),
}),
next.defineOriginChoreography({
name: "drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 66.6667}%)` },
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
}),
}),
next.defineOriginChoreography({
name: "drawer-close",
commitThreshold: 0.2,
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 66.6667}%)` },
target: { transform: `translateX(${(1 - progress) * 66.6667}%)` },
}),
}),
next.slideLeft,
next.slideRight,
next.fade,
];
const views = ["A", "B", "C", "D", "E"].map((name) =>
next.originView(component(name), undefined, { key: name.toLowerCase() }),
);
function randomAction(r: ReturnType<typeof rng>): next.OriginAction {
const choreography = r.pick(choreographies);
const target = r.pick(views);
const placement = r.pick(["above", "under", undefined] as const);
switch (r.int(6)) {
case 0:
return next.forward(target, choreography, { placement });
case 1:
return next.replace(target, choreography, { placement });
case 2:
case 3:
return next.back(choreography, { placement });
case 4:
return next.above(target, choreography, {
history: r.pick(["push", "replace", "back", undefined] as const),
});
default:
return next.under(target, choreography, {
history: r.pick(["push", "back"] as const),
});
}
}
type Pair<T> = [T, T];
export const stats = {
undecidedFinishes: 0,
boundary: 0,
begins: 0,
beginOk: 0,
};
interface Slot {
done: [boolean, boolean];
value: [unknown, unknown];
}
async function runSequence(seed: number, steps: number) {
const r = rng(seed);
const frames = installFrames();
const scenes = impls.map((impl) =>
impl.createOriginScene({
initial: seed % 3 === 0 ? [views[0]!, views[1]!] : views[0]!,
}),
) as Pair<next.OriginScene>;
const handles: Pair<next.OriginOperationHandle>[] = [];
const slots: Slot[] = [];
const log: string[] = [];
const track = (promises: Pair<Promise<unknown>>) => {
const slot: Slot = { done: [false, false], value: [undefined, undefined] };
promises.forEach(
(promise, index) =>
void settle(promise).then((value) => {
slot.done[index] = true;
slot.value[index] = value;
}),
);
slots.push(slot);
return slot;
};
const check = (label: string) => {
expect(snapshot(scenes[1]), `${label}\n${log.join("\n")}`).toEqual(
snapshot(scenes[0]),
);
for (const slot of slots) {
expect(slot.done[1], label).toBe(slot.done[0]);
expect(slot.value[1], label).toEqual(slot.value[0]);
}
};
const pickHandle = () => {
const liveIds = new Set(scenes[0].operations.value.map((o) => o.id));
const live = handles.filter(([handle]) => liveIds.has(handle.id));
return live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
};
const boundaryUpdate = (pair: Pair<next.OriginOperationHandle>) => {
// Bias toward the commit-decision boundaries.
const progress = r.chance(0.4)
? r.pick([0, 0.2, 0.35999, 0.36, 0.49999, 0.5, 1])
: r.next() * 1.4 - 0.2;
const velocity = r.chance(0.2)
? undefined
: r.chance(0.4)
? r.pick([-0.3, 0, 0.29999, 0.3, 0.30001, 0.31])
: r.next() * 4 - 2;
log.push(`update ${pair[0].id} ${progress} ${velocity}`);
pair.forEach((handle) => handle.update(progress, velocity));
};
for (let step = 0; step < steps; step += 1) {
const roll = r.next();
const nodeKeys = scenes[0].nodes.value.map((node) => node.key);
if (roll < 0.3 || handles.length === 0) {
const originKey = r.chance(0.03) ? "missing::0" : r.pick(nodeKeys);
const action = randomAction(r);
log.push(
`begin ${originKey} ${action.history} ${action.choreography.name}`,
);
const slot = track(
scenes.map((scene) => scene.begin(originKey, action)) as Pair<
Promise<next.OriginOperationHandle>
>,
);
await flush();
if (slot.done[0] && slot.done[1]) {
const [a, b] = slot.value as Pair<
{ ok: true; value: next.OriginOperationHandle } | { ok: false }
>;
stats.begins += 1;
if (a.ok && b.ok) {
stats.beginOk += 1;
handles.push([a.value, b.value]);
// Handles are compared by identity fields only.
slot.value = [
{ id: a.value.id, o: a.value.originKey, t: a.value.targetKey },
{ id: b.value.id, o: b.value.originKey, t: b.value.targetKey },
];
}
}
} else if (roll < 0.55) {
boundaryUpdate(pickHandle());
} else if (roll < 0.75) {
const pair = pickHandle();
if (r.chance(0.5)) boundaryUpdate(pair);
const options = {
commit: r.pick([true, false, undefined]),
animate: r.chance(0.5),
};
log.push(`finish ${pair[0].id} ${JSON.stringify(options)}`);
const live = scenes[0].operations.value.find((o) => o.id === pair[0].id);
if (live && options.commit === undefined) {
stats.undecidedFinishes += 1;
if (live.velocity >= 0.3 && live.velocity < 0.31) stats.boundary += 1;
}
track(
pair.map((handle) => handle.finish(options)) as Pair<Promise<boolean>>,
);
} else if (roll < 0.82) {
const pair = pickHandle();
const animate = r.pick([true, false, undefined]);
log.push(`cancel ${pair[0].id} ${animate}`);
track(
pair.map((handle) => handle.cancel({ animate })) as Pair<Promise<void>>,
);
} else {
const count = 1 + r.int(12);
log.push(`frames ${count}`);
for (let i = 0; i < count; i += 1) frames.advance(4 + r.int(40));
}
if (!r.chance(0.15)) await flush();
check(`seed ${seed} step ${step}`);
}
// Drain every running spring and compare the resting state.
for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16);
await flush();
check(`seed ${seed} drained`);
}
describe("scene: baseline vs rewrite", () => {
it("produces identical action and intent objects", () => {
const target = views[1]!;
const c = choreographies[0]!;
const calls: Array<(m: Impl) => unknown> = [
(m) => m.originAction(target, c),
(m) => m.originAction(target, c, { placement: "under", history: "back" }),
(m) => m.forward(target),
(m) => m.forward(target, { placement: "under" }),
(m) => m.forward(target, c),
(m) => m.forward(target, c, { placement: "under" }),
(m) => m.replace(target),
(m) => m.replace(target, c, {}),
(m) => m.back(),
(m) => m.back({ placement: "above" }),
(m) => m.back(c),
(m) => m.back(c, { placement: "above" }),
(m) => m.above(target),
(m) => m.above(target, { history: "replace" }),
(m) => m.above(target, c),
(m) => m.above(target, c, { history: "back" }),
(m) => m.under(target),
(m) => m.under(target, c, { history: "back" }),
(m) => m.normalizedEffect(undefined),
(m) => m.normalizedEffect({ transform: "x", layer: 2 }, -1),
];
for (const call of calls) {
const [a, b] = impls.map(call);
expect(b).toStrictEqual(a);
expect(Object.keys(b as object)).toEqual(Object.keys(a as object));
}
});
afterAll(() => console.log("STATS", JSON.stringify(stats)));
it("exports the same runtime names", () => {
expect(Object.keys(next).sort()).toEqual(Object.keys(base).sort());
});
const seeds = Number(process.env.SEEDS ?? 300);
for (let seed = 1; seed <= seeds; seed += 1) {
it(`random sequence ${seed}`, () => runSequence(seed, 60));
}
});
+1 -2
View File
@@ -2,7 +2,7 @@ import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
/* /*
* Differential verification against a previous core-v2 implementation. * Benchmarks against a previous core-v2 implementation.
* Run `setup-baseline.sh <git-ref>` first; see README.md. * Run `setup-baseline.sh <git-ref>` first; see README.md.
*/ */
export default defineConfig({ export default defineConfig({
@@ -10,7 +10,6 @@ export default defineConfig({
test: { test: {
root: __dirname, root: __dirname,
environment: "happy-dom", environment: "happy-dom",
include: ["*.diff.ts"],
testTimeout: 120_000, testTimeout: 120_000,
benchmark: { include: ["*.bench.ts"] }, benchmark: { include: ["*.bench.ts"] },
}, },