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
+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 ||