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,
originView,
useOrigin,
type OriginView,
} from "@native-vue-router/core-v2";
import { useDemoInstance } from "../lab-state";
import { unfocusPortal } from "../motions";
@@ -86,7 +87,7 @@ const slideSets: Record<NestedSceneKind, readonly SlideCopy[]> = {
],
};
function nestedView(kind: NestedSceneKind, index: number) {
function nestedView(kind: NestedSceneKind, index: number): OriginView {
const slides = slideSets[kind];
const copy = slides[index]!;
return originView(
@@ -98,7 +99,7 @@ function nestedView(kind: NestedSceneKind, index: number) {
...copy,
next:
index + 1 < slides.length
? () => nestedView(kind, index + 1)
? (): OriginView => nestedView(kind, index + 1)
: undefined,
},
{
+46 -21
View File
@@ -145,7 +145,7 @@ recognizer to it.
| Prop | Type | Default | Description |
| --------- | ------------------------- | ------- | ---------------------------------- |
| `as` | `string` | `"div"` | HTML tag used for the gesture host |
| `gesture` | `OriginGestureDefinition` | — | Preferred builder definition |
| `gesture` | `OriginGestureDefinition` | — | Builder definition |
Attributes, classes, and listeners not consumed as props are forwarded to the
rendered host.
@@ -156,8 +156,9 @@ rendered host.
</OriginGesture>
```
For compatibility, the component also accepts the legacy mutually exclusive
set of `direction`, `edge`, `threshold`, and `action` props.
The `gesture` prop is reactive. A new definition applies from the next
pointer-down; a gesture already in progress keeps the definition it started
with, so a parent re-render never interrupts a drag.
Use `useOriginGesture()` instead when an extra wrapper is undesirable.
@@ -190,9 +191,11 @@ binding, and derives the least-permissive shared `touch-action`:
- vertical only: `pan-x`;
- both axes: `none`.
Definitions should be immutable and stable for the lifetime of the rendered
surface. The owning page remains the visible declaration point for every
`.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
The `gestures` list is reactive, including its length and the derived
`touch-action`. Recognizers are kept per list position, so a re-render with new
definition objects never interrupts a gesture in progress; each change applies
from the next pointer-down. The owning page remains the visible declaration
point for every `.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
## Nested scenes
@@ -457,7 +460,8 @@ const openProfile = gesture.to
### `useOriginGesture(definition)`
Creates one primary-pointer, single-axis recognizer owned by the component that
calls it.
calls it. `definition` may also be a ref or getter; a changed definition
applies from the next pointer-down.
```ts
const open = useOriginGesture(
@@ -477,6 +481,7 @@ interface OriginGestureBinding {
readonly onPointermove: (event: PointerEvent) => void;
readonly onPointerup: (event: PointerEvent) => void;
readonly onPointercancel: () => void;
readonly onLostpointercapture: (event: PointerEvent) => void;
}
```
@@ -497,8 +502,14 @@ is normalized by the same dimension.
An asynchronous navigation factory is supported. If it resolves after the
pointer was released or cancelled, the stale result is discarded.
The legacy `OriginGestureOptions` object remains accepted. Its `edge` is a
number inferred from the side opposite `direction`, matching the previous API.
A captured gesture is cancelled, never left mid-transition, when its pointer
ends without a pointer-up: on `pointercancel`, on `lostpointercapture`, when a
new primary pointer goes down, or when the component unmounts.
Failures are reported with `console.warn` and the gesture is abandoned: a
`navigate()` factory that throws or rejects, or a scene that rejects the
operation (see `OriginSceneError`). Returning `null` declines silently, as
does losing a race to another gesture on the same origin.
## Choreographies and effects
@@ -575,10 +586,9 @@ When `finish()` does not explicitly override the decision, a target commits
when either:
- `progress >= commitThreshold`, default `0.36`; or
- release `velocity >= 0.3` progress units per second, at any progress.
`commitVelocity` is accepted on choreographies but is not currently consulted;
the flick threshold is the fixed `0.3` above.
- release `velocity >= commitVelocity`, default `0.3` progress units per
second, at any progress. Velocity is normalized by the gesture host's size,
so `1` is one full width (or height) per second.
The operation's intent becomes final before its spring settles.
@@ -662,8 +672,13 @@ can immediately begin its own outgoing operation, enabling X → Y → Z chains.
### `OriginOperationHandle`
`intent` reports the selected outcome (`undecided`, `commit`, or `cancel`). It
changes synchronously when `finish()` or `cancel()` is called.
`intent` changes synchronously when `finish()` or `cancel()` is called, before
the settling spring runs.
Calling `update()` while an operation settles catches it: the spring stops and
progress is under control again, for example to grab a transition that is
still animating. A later `finish()` decides the outcome, and any earlier
`finish()` promise resolves with that final outcome.
| Member | Description |
| ----------------------------- | --------------------------------------------------- |
@@ -672,6 +687,7 @@ changes synchronously when `finish()` or `cancel()` is called.
| `targetKey` | Created or retained target node key |
| `update(progress, velocity?)` | Update normalized interactive state |
| `finish(options?)` | Decide, settle, and return whether target committed |
| `intent` | Selected outcome: `undecided`, `commit`, `cancel` |
| `cancel(options?)` | Force cancellation and remove the target branch |
`OriginFinishOptions`:
@@ -737,12 +753,6 @@ Node-local action context:
- `previous`: recipe belonging to the mounted previous entry.
- `history`: recipes belonging to all retained previous entries.
### `MaybeOriginAction`
```ts
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
```
### `OriginEffect`
| Field | Description |
@@ -786,6 +796,21 @@ implementation types. They are exported by the current barrel but marked
## Errors and constraints
Scene operations reject with `OriginSceneError`, whose `code` identifies the
cause:
| Code | Cause |
| ------------------- | --------------------------------------------------- |
| `missing-node` | The node no longer exists |
| `missing-origin` | The origin node does not exist |
| `inert-origin` | The origin is parked or exposed |
| `origin-busy` | The origin already has a live outgoing operation |
| `invalid-action` | The action cannot be executed as written |
| `no-previous-entry` | Back was requested without a retained entry |
| `undecided-forward` | Back was requested through an unreleased forward |
| `target-busy` | The back target is transitioning or still presented |
| `outside-scene` | `useOrigin()` was called outside an `<OriginScene>` |
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
`OriginScene`.
- 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;
/**
* Reserved: normalized positive release velocity intended to commit a
* deliberate flick.
* Release velocity, in progress units per second, that commits a flick
* regardless of progress. Velocity is normalized by the gesture host's
* size, so `1` means one full host width (or height) per second.
*
* The scene currently commits any release whose velocity is at least `0.3`,
* regardless of this value. See `FLICK_COMMIT_VELOCITY` in the scene module.
* @defaultValue `0.3`
*/
readonly commitVelocity?: number;
/**
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useOriginGesture } from "../gesture/recognizer";
import type { OriginGestureProps } from "../gesture/definition";
import { useOriginGesture } from "../gesture/recognizer";
defineOptions({ name: "OriginGesture", inheritAttrs: false });
@@ -11,17 +11,10 @@ const props = withDefaults(defineProps<OriginGestureProps>(), {
/*
* This convenience component makes the declaration live exactly where the
* developer writes it. `useOriginGesture()` is also public for components that
* cannot accept an extra wrapper element. Props are read once, at setup; the
* legacy `action` prop is read lazily so it may change.
* cannot accept an extra wrapper element. A new `gesture` prop applies from
* the next pointer-down.
*/
const gesture = useOriginGesture(
props.gesture ?? {
direction: props.direction,
edge: props.edge,
threshold: props.threshold,
action: (context) => props.action?.(context),
},
);
const gesture = useOriginGesture(() => props.gesture);
</script>
<template>
@@ -34,6 +27,7 @@ const gesture = useOriginGesture(
@pointermove="gesture.onPointermove"
@pointerup="gesture.onPointerup"
@pointercancel="gesture.onPointercancel"
@lostpointercapture="gesture.onLostpointercapture"
>
<slot />
</component>
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import type { OriginGestureSurfaceProps } from "../gesture/definition";
import { touchActionFor, useOriginGesture } from "../gesture/recognizer";
import { useOriginGestures } from "../gesture/recognizer";
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
@@ -14,29 +13,7 @@ const props = withDefaults(defineProps<OriginGestureSurfaceProps>(), {
* This convenience host only installs them, combines their browser scrolling
* requirements, and forwards a pointer sequence to every recognizer.
*/
const bindings = props.gestures.map((definition) =>
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());
const surface = useOriginGestures(() => props.gestures);
</script>
<template>
@@ -44,11 +21,12 @@ const onPointercancel = () =>
:is="as"
v-bind="$attrs"
class="nvo-gesture"
:style="[$attrs.style, surfaceStyle]"
@pointerdown="onPointerdown"
@pointermove="onPointermove"
@pointerup="onPointerup"
@pointercancel="onPointercancel"
:style="[$attrs.style, surface.style]"
@pointerdown="surface.onPointerdown"
@pointermove="surface.onPointermove"
@pointerup="surface.onPointerup"
@pointercancel="surface.onPointercancel"
@lostpointercapture="surface.onLostpointercapture"
>
<slot />
</component>
+23 -84
View File
@@ -1,6 +1,6 @@
import type { CSSProperties } from "vue";
import type { OriginChoreography } from "../choreography";
import type { MaybeOriginAction, OriginNavigationIntent } from "../navigation";
import type { OriginNavigationIntent } from "../navigation";
import type { OriginContext } from "../scene/types";
import type { OriginRect } from "../shared";
@@ -152,7 +152,7 @@ export type OriginGestureNavigationFactory = (
* represented as an `anywhere` start rule.
*/
export interface OriginGestureDefinition {
/** Discriminator used by the compatibility recognizer overload. */
/** Identifies a definition produced by the {@link gesture} builder. */
readonly kind: "origin-gesture-definition";
/** Pointer-down eligibility policy. */
readonly start: OriginGestureStart;
@@ -260,47 +260,17 @@ export interface OriginGestureBuilder {
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}.
*
* Spread or attach all four handlers to the same `HTMLElement`. Apply
* {@link style} as well so native scrolling is preserved on the cross-axis.
* Attach all five handlers to the same `HTMLElement`. Apply {@link style} as
* well so native scrolling is preserved on the cross-axis.
*/
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>;
/** Pointer-down handler that records a potentially eligible gesture. */
readonly onPointerdown: (event: PointerEvent) => void;
@@ -310,64 +280,31 @@ export interface OriginGestureBinding {
readonly onPointerup: (event: PointerEvent) => void;
/** Pointer-cancel handler that abandons any captured operation. */
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 ----------------------------------------------------------
/** Props shared by both `OriginGesture` declaration styles. */
export interface OriginGestureBaseProps {
/** Public props accepted by the `OriginGesture` convenience component. */
export interface OriginGestureProps {
/**
* Native HTML tag rendered as the gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
}
/** Builder-definition props accepted by the `OriginGesture` component. */
export interface OriginGestureDefinitionProps extends OriginGestureBaseProps {
/** Immutable definition produced by the {@link gesture} builder. */
/**
* Immutable definition produced by the {@link gesture} builder. Replacing
* it takes effect from the next pointer-down; a gesture in progress keeps
* the definition it started with.
*/
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. */
export interface OriginGestureSurfaceProps {
/**
@@ -380,7 +317,9 @@ export interface OriginGestureSurfaceProps {
* Complete immutable definitions installed on the shared host.
*
* 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[];
}
+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 type { OriginContext, OriginOperationHandle } from "../scene/types";
import { clamp01, measureRect, type OriginRect } from "../shared";
import { clamp01, measureRect, warn, type OriginRect } from "../shared";
import type {
OriginGestureBinding,
OriginGestureCommitHandler,
OriginGestureCompletionContext,
OriginGestureCompletionPredicate,
OriginGestureDefinition,
OriginGestureCompletionContext,
OriginGestureDirection,
OriginGestureDistance,
OriginGestureOptions,
OriginGesturePoint,
OriginGestureStart,
} from "./definition";
@@ -285,33 +284,13 @@ const DEFAULT_THRESHOLD = 8;
/** Ratio by which directed movement must exceed cross-axis movement. */
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. */
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
/** State of one admitted pointer sequence, from pointer-down to release. */
interface PointerSession {
/** Definition current at pointer-down; later changes do not affect it. */
readonly definition: OriginGestureDefinition;
readonly pointerId: number;
readonly host: HTMLElement;
readonly bounds: OriginRect;
@@ -322,7 +301,7 @@ interface PointerSession {
readonly tracker: DirectionalTracker;
/** Movement passed the recognition threshold and the pointer is captured. */
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;
}
@@ -333,119 +312,58 @@ interface PointerSession {
*
* 1. **down**: an eligible primary pointer opens a session.
* 2. **move**: once directed movement passes the threshold, the pointer is
* captured and the policy's action is resolved; a non-null action begins
* an operation, while a declined one closes the session. Every later move
* captured and `.navigate()` is resolved; a non-null intent begins an
* operation, while a declined one closes the session. Every later move
* updates the operation's progress.
* 3. **up**: the session closes and the operation finishes, using the
* policy's completion predicate when present.
* 4. **cancel**: the session closes and the operation is cancelled.
* definition's `.complete()` predicate when present.
* 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
* that its session is still current, so a stale resolution never creates or
* Navigation and `begin()` are asynchronous. Each continuation checks that
* its session is still current, so a stale resolution never creates or
* updates an orphan operation.
*/
export class GestureRecognizer {
private session: PointerSession | null = null;
constructor(
readonly policy: RecognizerPolicy,
/** Reads the current definition; it may change between sessions. */
private readonly definitionOf: () => OriginGestureDefinition,
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 {
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;
if (
!event.isPrimary ||
event.button !== 0 ||
!(host instanceof HTMLElement) ||
isIgnoredTarget(event.target)
)
return;
const definition = this.definitionOf();
const bounds = measureRect(host);
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.
event.stopPropagation();
const horizontal = isHorizontal(this.policy.direction);
const horizontal = isHorizontal(definition.direction);
this.session = {
definition,
pointerId: event.pointerId,
host,
bounds,
origin,
start: pointOf(event, bounds),
startTime: event.timeStamp,
tracker: new DirectionalTracker(this.policy.direction, event, () =>
tracker: new DirectionalTracker(definition.direction, event, () =>
horizontal ? host.clientWidth : host.clientHeight,
),
captured: false,
@@ -458,19 +376,26 @@ export class GestureRecognizer {
if (!session) return;
if (!session.captured) {
if (!this.recognizes(session, event)) return;
if (!GestureRecognizer.recognizes(session, event)) return;
session.captured = true;
session.host.setPointerCapture?.(session.pointerId);
event.preventDefault();
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.
if (this.session !== session) return;
if (!action) {
this.session = null;
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 {
event.preventDefault();
session.tracker.sample(event);
@@ -489,11 +414,11 @@ export class GestureRecognizer {
this.session = null;
if (!session.captured) return;
const { tracker } = session;
const { tracker, definition } = session;
tracker.sample(event, true);
const context: OriginGestureCompletionContext = {
origin: session.origin,
direction: this.policy.direction,
direction: definition.direction,
progress: tracker.progress,
velocity: tracker.velocity,
distance: tracker.distance,
@@ -505,7 +430,7 @@ export class GestureRecognizer {
start: session.start,
current: pointOf(event, session.bounds),
};
const commit = this.policy.completion?.(context);
const commit = definition.completion?.(context);
const { progress, velocity } = tracker;
const handle = session.operation ? await session.operation : null;
@@ -515,24 +440,22 @@ export class GestureRecognizer {
const settled = handle.finish(
commit === undefined ? undefined : { commit },
);
if (handle.intent === "commit") this.notifyCommit(context);
if (handle.intent === "commit")
GestureRecognizer.notifyCommit(definition, context);
await settled;
}
/** Run commit handlers; a failing handler never interrupts the others. */
private notifyCommit(context: OriginGestureCompletionContext) {
for (const handler of this.policy.commitHandlers) {
try {
handler(context);
} catch (error) {
queueMicrotask(() => {
throw error;
});
}
}
/** Pointer capture ended without a pointer-up; cancel like pointer-cancel. */
pointerCaptureLost(event: PointerEvent): Promise<void> {
return this.sessionFor(event) ? this.abandon() : Promise.resolve();
}
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;
this.session = null;
if (!session?.captured) return;
@@ -546,30 +469,122 @@ export class GestureRecognizer {
}
/** 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);
return (
distance >= this.policy.threshold &&
distance >= crossDistance * this.policy.axisDominance
distance >= (recognition.threshold ?? DEFAULT_THRESHOLD) &&
distance >=
crossDistance * (recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE)
);
}
/**
* Resolve the policy's action against the origin's current context. A
* declined or failed action resolves to `null`.
* Resolve the definition's navigation against the origin's current context
* 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 {
return Promise.resolve(
this.policy.action(this.origin.context.value),
).catch(() => null);
} catch {
return Promise.resolve(null);
definition.navigation(this.origin.context.value),
).then(
(navigation) =>
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.
@@ -579,14 +594,14 @@ export class GestureRecognizer {
*
* Builder definitions may independently describe their pointer-down region
* and movement direction. Omitting `.from` recognizes pointer-down across the
* whole element. The legacy options object remains supported; its `edge` is
* inferred from the opposite side of its movement direction.
* whole element.
*
* Interactive controls and anything inside
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
* 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.
* @throws If called outside a component rendered by `OriginScene`.
*
@@ -602,21 +617,7 @@ export class GestureRecognizer {
* ```
*/
export function useOriginGesture(
definition: OriginGestureDefinition | OriginGestureOptions,
definition: MaybeRefOrGetter<OriginGestureDefinition>,
): OriginGestureBinding {
const recognizer = GestureRecognizer.create(definition, useOrigin());
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(),
};
return useOriginGestures(() => [toValue(definition)]);
}
+1
View File
@@ -50,6 +50,7 @@ export {
export type * from "./scene/types";
export type { OriginNodeScope, UseOrigin } from "./scene/scene";
export { OriginSceneError, type OriginSceneErrorCode } from "./scene/graph";
export {
createOriginScene,
originNodeScopeKey,
-10
View File
@@ -147,16 +147,6 @@ export interface OriginAction {
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}. */
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);
});
});
+147 -34
View File
@@ -24,6 +24,43 @@ export interface SceneNodeRecord {
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
* 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
* 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. */
export const shouldCommit = (
@@ -42,11 +79,10 @@ export const shouldCommit = (
options: OriginFinishOptions,
): boolean => {
if (options.commit !== undefined) return options.commit;
const threshold =
operation.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD;
const { commitThreshold, commitVelocity } = operation.choreography;
return (
operation.progress >= threshold ||
(operation.progress >= 0 && operation.velocity >= FLICK_COMMIT_VELOCITY)
operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD) ||
operation.velocity >= (commitVelocity ?? DEFAULT_COMMIT_VELOCITY)
);
};
@@ -156,7 +192,10 @@ export class SceneGraph {
contextFor(nodeKey: string): OriginContext {
const node = this.nodes.get(nodeKey);
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);
return {
nodeKey,
@@ -199,7 +238,8 @@ export class SceneGraph {
const origin = this.assertCanOriginate(originKey);
const history = action.history ?? "push";
if (action.choreography.persistAtRest && history !== "push")
throw new Error(
throw new OriginSceneError(
"invalid-action",
"persistAtRest choreographies require retained-history push navigation.",
);
@@ -270,7 +310,11 @@ export class SceneGraph {
origin.state = "parked";
} else {
// 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);
}
@@ -281,33 +325,37 @@ export class SceneGraph {
/**
* Finalize a cancelled operation, restoring the origin.
*
* A cancelled back re-parks its retained target, or re-attaches the resting
* edge it suspended. A cancelled push/replace removes its new target branch.
* Everything the edge created or now positions is undone. Entries from the
* 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 {
const operation = this.operations.get(id);
if (!operation) return;
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 retained = origin ? this.retainedKeys(origin) : new Set<string>();
operation.phase = "finished";
this.detach(operation);
this.suspendedEdges.delete(id);
if (operation.history === "back") {
if (suspendedEdge && origin && target) {
// Put the retained page back into its former position, then reconnect
// the resting page→drawer edge. Both nodes keep their VNodes.
this.transferPosition(origin, target);
this.attach(suspendedEdge);
origin.incomingOperationId = suspendedEdge.id;
target.state = "exposed";
} else if (target) {
target.incomingOperationId = undefined;
target.state = "parked";
}
if (suspendedEdge && origin && entry && current === entry) {
// Put the retained page back into its former position, then reconnect
// the resting page→drawer edge. Both nodes keep their VNodes.
this.discardOutgoing(entry.key, new Set(retained));
this.transferPosition(origin, entry);
this.attach(suspendedEdge);
origin.incomingOperationId = suspendedEdge.id;
entry.state = "exposed";
} 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);
@@ -318,13 +366,18 @@ export class SceneGraph {
private assertCanOriginate(originKey: string): SceneNodeRecord {
const origin = this.nodes.get(originKey);
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")
throw new Error(
throw new OriginSceneError(
"inert-origin",
`Cannot animate from ${origin.state} origin "${originKey}".`,
);
if (this.liveOutgoingOf(originKey))
throw new Error(
throw new OriginSceneError(
"origin-busy",
`Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.",
);
@@ -346,12 +399,14 @@ export class SceneGraph {
} {
const previousKey = origin.previousNodeKey;
if (!previousKey)
throw new Error(
throw new OriginSceneError(
"no-previous-entry",
`Cannot go back from origin "${origin.key}" without a previous entry.`,
);
const target = this.nodes.get(previousKey);
if (!target)
throw new Error(
throw new OriginSceneError(
"no-previous-entry",
`Cannot go back to missing retained entry "${previousKey}".`,
);
@@ -359,7 +414,8 @@ export class SceneGraph {
const incoming = this.incomingOf(origin);
if (incoming?.originKey === target.key) {
if (incoming.intent !== "commit")
throw new Error(
throw new OriginSceneError(
"undecided-forward",
"Cannot go back through an undecided forward operation.",
);
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))
throw new Error(
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" already has an outgoing operation.`,
);
if (target.incomingOperationId)
throw new Error(
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" is already transitioning.`,
);
return { target, suspendedEdge };
@@ -392,7 +461,8 @@ export class SceneGraph {
history: Exclude<OriginHistoryMode, "back">,
): SceneNodeRecord {
if (!action.target)
throw new Error(
throw new OriginSceneError(
"invalid-action",
`A "${history}" operation requires a target view recipe.`,
);
// 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);
}
/**
* 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 {
const resting = this.presentedOutgoingOf(node.key);
if (resting?.phase === "finished") {
node.state = "exposed";
return;
}
node.state = "active";
this.refreshPresentedState(node);
}
@@ -490,9 +569,42 @@ export class SceneGraph {
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
* 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 {
if (visited.has(nodeKey)) return;
@@ -501,6 +613,7 @@ export class SceneGraph {
for (const node of [...this.nodes.values()])
if (node.previousNodeKey === nodeKey)
this.removeBranch(node.key, visited);
this.discardOutgoing(nodeKey, visited);
for (const operation of [...this.operations.values()])
if (
operation.originKey === nodeKey ||
+87 -18
View File
@@ -14,6 +14,7 @@ import {
type OriginEffect,
type OriginEffectSet,
type OriginOperationIntent,
type OriginOperationPhase,
} from "../choreography";
import type { OriginAction, OriginView } from "../navigation";
import {
@@ -24,7 +25,12 @@ import {
windowRect,
type OriginRect,
} from "../shared";
import { SceneGraph, shouldCommit, type SceneNodeRecord } from "./graph";
import {
OriginSceneError,
SceneGraph,
shouldCommit,
type SceneNodeRecord,
} from "./graph";
import type {
CreateOriginSceneOptions,
MutableOriginOperation,
@@ -277,6 +283,12 @@ class Scene implements OriginScene {
* operation's progress, phase, intent, rects, or the viewport change, so
* 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<
MutableOriginOperation,
ComputedRef<OriginEffectSet>
@@ -334,25 +346,73 @@ class Scene implements OriginScene {
/**
* 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}.
*/
async finish(id: number, options: OriginFinishOptions = {}) {
finish(id: number, options: OriginFinishOptions = {}): Promise<boolean> {
const operation = this.graph.operation(id);
if (!operation) return false;
if (operation.phase === "finished") return operation.intent === "commit";
if (!operation) return Promise.resolve(false);
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
// operation while this one is only visually settling.
const commit = shouldCommit(operation, options);
operation.intent = commit ? "commit" : "cancel";
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.
if (!this.graph.operation(id)) return commit;
if (commit) return this.graph.commit(id);
this.graph.cancel(id);
return false;
const finished = (operation.phase as OriginOperationPhase) === "finished";
if (!this.graph.operation(operation.id) || finished) {
const committed = this.graph.operation(operation.id)
? 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 {
@@ -381,10 +441,17 @@ class Scene implements OriginScene {
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) {
const operation = this.graph.operation(id);
if (!operation) return;
/**
* Move an operation to 0 or 1, animated unless motion is disabled. The
* spring stops early once `current()` reports the release was caught or
* superseded.
*/
private async settle(
operation: MutableOriginOperation,
target: 0 | 1,
animate: boolean,
current: () => boolean,
) {
if (!animate || prefersReducedMotion()) {
operation.progress = target;
operation.velocity = 0;
@@ -392,10 +459,9 @@ class Scene implements OriginScene {
}
const from = { position: operation.progress, velocity: operation.velocity };
await runSpring(from, target, (progress, velocity) => {
const live = this.graph.operation(id);
if (!live) return false;
live.progress = progress;
live.velocity = velocity;
if (!this.graph.operation(operation.id) || !current()) return false;
operation.progress = progress;
operation.velocity = velocity;
return true;
});
}
@@ -533,7 +599,10 @@ export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
export function useOrigin(): UseOrigin {
const scope = inject(originNodeScopeKey);
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 context = computed(() => scene.contextFor(nodeKey));
+6
View File
@@ -50,3 +50,9 @@ export function prefersReducedMotion(): boolean {
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`)
by driving both with identical randomized inputs and requiring identical
observable results after every step.
Compares core-v2's performance against an earlier implementation (by default
`main`).
From the repository root:
```sh
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=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
```
- `scene.diff.ts` runs random `begin`/`update`/`finish`/`cancel`/animation
frame sequences against both scenes and compares nodes, operations, roots,
every node's composited style, interactivity, contexts, and every promise
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
same gestures (builder definitions, legacy options, `OriginGesture`, and
`OriginGestureSurface`), dispatches identical random pointer streams, and
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.
`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.
Behavioural verification lives with the unit tests: `src/property.test.ts`
drives random operation sequences and pointer streams and checks the scene's
structural invariants after every step. Set `PROPERTY_SEEDS` to run more.
-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 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 {
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";
/*
* 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.
*/
export default defineConfig({
@@ -10,7 +10,6 @@ export default defineConfig({
test: {
root: __dirname,
environment: "happy-dom",
include: ["*.diff.ts"],
testTimeout: 120_000,
benchmark: { include: ["*.bench.ts"] },
},