The previous round regressed the demo against main: dropped frames while a finger drags, and swipes ignored when they start imperfectly. Frame drops: hosts used touch-action: manipulation plus a blocking touchmove listener, so Chrome waited for JavaScript on every touch move (~30% of frames over 20ms in a drag, p95 28ms). Hosts again use per-axis touch-action like main (pan-y for horizontal gestures, and so on), and attach no touch listener. Flaky recognition: an irreversible decision after 6px dropped swipes with a wobbly or backward start. Recognition again measures from the pointer-down point until the pointer lifts, as on main. Scrolling beside gestures, natively: - .from.scrollEdge() uses direction-specific touch-action while its scroller is at the edge (pan-x pan-down for drag-down at the top), the pull-to-refresh pattern from the Pointer Events spec. A passive scroll listener keeps it current; no touch handling blocks the browser. - Where directional values are unsupported (Safari), scroll-edge gestures alone fall back to claiming touches at the browser's first cancelable touchmove. - Hosts publish touch-action as --nvo-touch-action and the stylesheet applies it to descendants, so nested scroll containers keep it and horizontal swipes over lists are not cancelled. Ignored subtrees get touch-action: auto back. - Bindings gain listeners and ref (spread on the host); the components use them. Feel tests (npm run test:feel): Chromium with emulated touch checks recognition on ten human-like traces, frame pacing budgets while dragging (p95 <= 20ms, <= 5% over 20ms) for a swipe and a scroll-edge dismiss, and the behaviour checks. Against the previous code they fail on exactly the reported regressions; now 23/23 pass. On the demo, all traces match main and drag frame pacing matches or beats it. Commit thresholds are unchanged (0.25 distance, 0.2 flick). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
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 "../src";
|
|
import { shouldCommit } from "../src/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 a child losing implicit touch capture to the host", async () => {
|
|
const Root = defineComponent({
|
|
setup: () => () =>
|
|
h(OriginGesture, { id: "host", gesture: definition }, () =>
|
|
h("span", { id: "child" }, "x"),
|
|
),
|
|
});
|
|
const { scene, host } = await mount(Root);
|
|
await dragLeft(host);
|
|
expect(scene.operations.value).toHaveLength(1);
|
|
|
|
// Taking capture on the host makes the touched child lose its implicit
|
|
// capture; that event bubbles to the host.
|
|
host
|
|
.querySelector("#child")!
|
|
.dispatchEvent(pointer("lostpointercapture", 120, 110));
|
|
await flush();
|
|
expect(scene.operations.value).toHaveLength(1);
|
|
});
|
|
|
|
it("ignores lostpointercapture after a normal release", async () => {
|
|
const { scene, host } = await mount(gestureHost(() => definition));
|
|
await dragLeft(host);
|
|
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", 140, 100));
|
|
await flush();
|
|
expect(scene.operations.value).toHaveLength(1);
|
|
// A short, held release (20%) cancels, leaving the origin interactive.
|
|
host.dispatchEvent(pointer("pointerup", 140, 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.2", () => {
|
|
expect(shouldCommit(operation(undefined, 0.19), {})).toBe(false);
|
|
expect(shouldCommit(operation(undefined, 0.2), {})).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("lets a fast flick's direction veto the distance", () => {
|
|
const at = (progress: number, velocity: number) =>
|
|
({
|
|
progress,
|
|
velocity,
|
|
choreography: { effects: () => ({}) },
|
|
}) as unknown as Parameters<typeof shouldCommit>[0];
|
|
// Past the 0.36 threshold, but flicked back toward the start: cancels.
|
|
expect(shouldCommit(at(0.8, -0.5), {})).toBe(false);
|
|
// Barely started, but flicked forward: commits.
|
|
expect(shouldCommit(at(0.05, 0.5), {})).toBe(true);
|
|
// Slow releases fall back to distance, in either direction.
|
|
expect(shouldCommit(at(0.8, -0.1), {})).toBe(true);
|
|
expect(shouldCommit(at(0.2, 0.1), {})).toBe(false);
|
|
});
|
|
|
|
it("still yields to an explicit decision", () => {
|
|
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
|
|
});
|
|
});
|