From 2e857df8069095357b3d1f32ce83db70406c1d5d Mon Sep 17 00:00:00 2001
From: f0rdream <14049186+f0rdream@users.noreply.github.com>
Date: Fri, 5 Dec 2025 16:09:53 +0800
Subject: [PATCH 1/3] feat: add gesture-runtime
---
.changeset/olive-bugs-prove.md | 5 +
biome.jsonc | 1 +
eslint.config.js | 3 +
packages/lynx/gesture-runtime/CHANGELOG.md | 0
packages/lynx/gesture-runtime/README.md | 25 ++
.../__test__/gesture-callback.test.tsx | 323 ++++++++++++++++
.../__test__/gesture-clone.test.ts | 250 +++++++++++++
.../gesture-composition-advanced.test.ts | 274 ++++++++++++++
.../__test__/gesture-config.test.ts | 221 +++++++++++
.../__test__/gesture-plain.test.ts | 200 ++++++++++
.../__test__/gesture-react.test.tsx | 348 ++++++++++++++++++
.../__test__/gesture-relations.test.tsx | 191 ++++++++++
.../__test__/is-worklet-object.test.ts | 86 +++++
.../__test__/type-safety.test.ts | 98 +++++
.../__test__/utils/callback.ts | 64 ++++
.../gesture-runtime/__test__/utils/setup.ts | 3 +
packages/lynx/gesture-runtime/package.json | 38 ++
packages/lynx/gesture-runtime/rslib.config.ts | 17 +
.../lynx/gesture-runtime/src/baseGesture.ts | 303 +++++++++++++++
.../lynx/gesture-runtime/src/composition.ts | 170 +++++++++
.../src/defaultScrollGesture.ts | 24 ++
.../gesture-runtime/src/env_types/global.d.ts | 30 ++
.../lynx/gesture-runtime/src/flingGesture.ts | 22 ++
.../gesture-runtime/src/gestureInterface.ts | 302 +++++++++++++++
packages/lynx/gesture-runtime/src/index.ts | 48 +++
.../gesture-runtime/src/longPressGesture.ts | 31 ++
.../lynx/gesture-runtime/src/panGesture.ts | 49 +++
.../lynx/gesture-runtime/src/tapGesture.ts | 33 ++
.../lynx/gesture-runtime/src/useGesture.ts | 36 ++
.../lynx/gesture-runtime/src/utils/const.ts | 6 +
.../src/utils/isWorkletObject.ts | 14 +
.../src/utils/removeUndefined.ts | 10 +
.../lynx/gesture-runtime/tsconfig.build.json | 20 +
packages/lynx/gesture-runtime/tsconfig.json | 7 +
.../lynx/gesture-runtime/tsconfig.test.json | 7 +
.../lynx/gesture-runtime/vitest.config.ts | 17 +
packages/lynx/tsconfig.json | 11 +
pnpm-lock.yaml | 29 +-
tsconfig.json | 3 +
39 files changed, 3312 insertions(+), 7 deletions(-)
create mode 100644 .changeset/olive-bugs-prove.md
create mode 100644 packages/lynx/gesture-runtime/CHANGELOG.md
create mode 100644 packages/lynx/gesture-runtime/README.md
create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-callback.test.tsx
create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-clone.test.ts
create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-composition-advanced.test.ts
create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-config.test.ts
create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts
create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx
create mode 100644 packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx
create mode 100644 packages/lynx/gesture-runtime/__test__/is-worklet-object.test.ts
create mode 100644 packages/lynx/gesture-runtime/__test__/type-safety.test.ts
create mode 100644 packages/lynx/gesture-runtime/__test__/utils/callback.ts
create mode 100644 packages/lynx/gesture-runtime/__test__/utils/setup.ts
create mode 100644 packages/lynx/gesture-runtime/package.json
create mode 100644 packages/lynx/gesture-runtime/rslib.config.ts
create mode 100644 packages/lynx/gesture-runtime/src/baseGesture.ts
create mode 100644 packages/lynx/gesture-runtime/src/composition.ts
create mode 100644 packages/lynx/gesture-runtime/src/defaultScrollGesture.ts
create mode 100644 packages/lynx/gesture-runtime/src/env_types/global.d.ts
create mode 100644 packages/lynx/gesture-runtime/src/flingGesture.ts
create mode 100644 packages/lynx/gesture-runtime/src/gestureInterface.ts
create mode 100644 packages/lynx/gesture-runtime/src/index.ts
create mode 100644 packages/lynx/gesture-runtime/src/longPressGesture.ts
create mode 100644 packages/lynx/gesture-runtime/src/panGesture.ts
create mode 100644 packages/lynx/gesture-runtime/src/tapGesture.ts
create mode 100644 packages/lynx/gesture-runtime/src/useGesture.ts
create mode 100644 packages/lynx/gesture-runtime/src/utils/const.ts
create mode 100644 packages/lynx/gesture-runtime/src/utils/isWorkletObject.ts
create mode 100644 packages/lynx/gesture-runtime/src/utils/removeUndefined.ts
create mode 100644 packages/lynx/gesture-runtime/tsconfig.build.json
create mode 100644 packages/lynx/gesture-runtime/tsconfig.json
create mode 100644 packages/lynx/gesture-runtime/tsconfig.test.json
create mode 100644 packages/lynx/gesture-runtime/vitest.config.ts
create mode 100644 packages/lynx/tsconfig.json
diff --git a/.changeset/olive-bugs-prove.md b/.changeset/olive-bugs-prove.md
new file mode 100644
index 0000000000..3264f1bd71
--- /dev/null
+++ b/.changeset/olive-bugs-prove.md
@@ -0,0 +1,5 @@
+---
+'@lynx-js/gesture-runtime': minor
+---
+
+Initialize `'@lynx-js/gesture-runtime`
diff --git a/biome.jsonc b/biome.jsonc
index eb00fcb238..844cb06796 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -53,6 +53,7 @@
"packages/testing-library/**",
"packages/react/testing-library/**",
+ "packages/lynx/gesture-runtime/__test__/**"
],
"rules": {
// We are migrating from ESLint to Biome
diff --git a/eslint.config.js b/eslint.config.js
index 76db4d4e5c..ce07104a1f 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -97,6 +97,9 @@ export default tseslint.config(
// testing-library
'packages/testing-library/**',
'packages/react/testing-library/**',
+
+ // gesture-runtime-testing
+ 'packages/lynx/gesture-runtime/__test__/**',
],
},
js.configs.recommended,
diff --git a/packages/lynx/gesture-runtime/CHANGELOG.md b/packages/lynx/gesture-runtime/CHANGELOG.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/lynx/gesture-runtime/README.md b/packages/lynx/gesture-runtime/README.md
new file mode 100644
index 0000000000..02eb265321
--- /dev/null
+++ b/packages/lynx/gesture-runtime/README.md
@@ -0,0 +1,25 @@
+# Lynx Gesture Runtime
+
+`@lynx-js/gesture-runtime` provides typed gesture primitives and simple composition utilities for Lynx.
+
+## Install
+
+- `pnpm add @lynx-js/gesture-runtime @lynx-js/react`
+
+## Usage
+
+```tsx
+import { useGesture, PanGesture } from '@lynx-js/gesture-runtime';
+
+export default function Example() {
+ const pan = useGesture(PanGesture).onUpdate((event, stateManager) => {
+ 'main thread';
+ stateManager.active();
+ });
+ return ;
+}
+```
+
+## Docs
+
+- See the dedicated guide website for full documentation and API details.
diff --git a/packages/lynx/gesture-runtime/__test__/gesture-callback.test.tsx b/packages/lynx/gesture-runtime/__test__/gesture-callback.test.tsx
new file mode 100644
index 0000000000..f33aba0d37
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/gesture-callback.test.tsx
@@ -0,0 +1,323 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import type { MockInstance } from 'vitest';
+
+import { useRef } from '@lynx-js/react';
+import { act, render } from '@lynx-js/react/testing-library';
+
+import { PanGesture } from '../src/index.js';
+import { useGesture } from '../src/useGesture.js';
+import {
+ MockGestureManager,
+ genEventObj,
+ triggerGestureCallback,
+} from './utils/callback.js';
+
+describe('gestures mt', () => {
+ let spySetGesture: MockInstance;
+ let _gestureNode: any;
+
+ beforeEach(() => {
+ spySetGesture = vi.spyOn(
+ lynxTestingEnv.mainThread.globalThis,
+ '__SetGestureDetector',
+ ).mockImplementation(function(
+ node: any,
+ id: number,
+ type: number,
+ config: any,
+ relationMap: Record,
+ ) {
+ node.gesture = {
+ id,
+ type,
+ config,
+ relationMap,
+ };
+
+ _gestureNode = node;
+ });
+ });
+
+ afterEach(() => {
+ spySetGesture.mockRestore();
+ _gestureNode = null;
+ });
+
+ test('bind gestures should call papi correctly', async () => {
+ let _panGesture;
+ const mockGestureManager = new MockGestureManager();
+
+ const App = () => {
+ const panGesture = useGesture(PanGesture);
+ panGesture.onBegin((event, stateManager) => {
+ 'main thread';
+ globalThis._eventObj = event;
+ });
+
+ _panGesture = panGesture;
+ return (
+
+
+
+ );
+ };
+
+ await act(() => {
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ });
+
+ await act(() => {
+ const eventObj = genEventObj(_gestureNode, {
+ params: { x: 0, y: 1 },
+ });
+
+ triggerGestureCallback(
+ _gestureNode,
+ 'onBegin',
+ eventObj,
+ mockGestureManager,
+ );
+ });
+
+ expect(globalThis._eventObj.params.x).toBe(0);
+ expect(globalThis._eventObj.params.y).toBe(1);
+
+ delete globalThis._eventObj;
+ });
+
+ test('stateManager.active should call __SetGestureDetector correctly', async () => {
+ let _panGesture;
+ const mockGestureManager = new MockGestureManager();
+
+ const App = () => {
+ const panGesture = useGesture(PanGesture);
+ panGesture.onBegin((event, stateManager) => {
+ 'main thread';
+ stateManager.active();
+ });
+
+ _panGesture = panGesture;
+ return (
+
+
+
+ );
+ };
+
+ await act(() => {
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ });
+
+ await act(() => {
+ const eventObj = genEventObj(_gestureNode, {
+ params: { x: 0, y: 1 },
+ });
+
+ triggerGestureCallback(
+ _gestureNode,
+ 'onBegin',
+ eventObj,
+ mockGestureManager,
+ );
+ });
+
+ expect(mockGestureManager.__SetGestureState).toBeCalledWith(
+ _gestureNode,
+ _gestureNode.gesture.id,
+ 1,
+ );
+ });
+
+ test('stateManager.fail should call __SetGestureDetector correctly', async () => {
+ let _panGesture;
+ const mockGestureManager = new MockGestureManager();
+
+ const App = () => {
+ const panGesture = useGesture(PanGesture);
+ panGesture.onBegin((event, stateManager) => {
+ 'main thread';
+ stateManager.fail();
+ });
+
+ _panGesture = panGesture;
+ return (
+
+
+
+ );
+ };
+
+ await act(() => {
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ });
+
+ await act(() => {
+ const eventObj = genEventObj(_gestureNode, {
+ params: { x: 0, y: 1 },
+ });
+
+ triggerGestureCallback(
+ _gestureNode,
+ 'onBegin',
+ eventObj,
+ mockGestureManager,
+ );
+ });
+
+ expect(mockGestureManager.__SetGestureState).toBeCalledWith(
+ _gestureNode,
+ _gestureNode.gesture.id,
+ 2,
+ );
+ });
+
+ test('stateManager.end should call __SetGestureDetector correctly', async () => {
+ let _panGesture;
+ const mockGestureManager = new MockGestureManager();
+
+ const App = () => {
+ const panGesture = useGesture(PanGesture);
+ panGesture.onBegin((event, stateManager) => {
+ 'main thread';
+ stateManager.end();
+ });
+
+ _panGesture = panGesture;
+ return (
+
+
+
+ );
+ };
+
+ await act(() => {
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ });
+
+ await act(() => {
+ const eventObj = genEventObj(_gestureNode, {
+ params: { x: 0, y: 1 },
+ });
+
+ triggerGestureCallback(
+ _gestureNode,
+ 'onBegin',
+ eventObj,
+ mockGestureManager,
+ );
+ });
+
+ expect(mockGestureManager.__SetGestureState).toBeCalledWith(
+ _gestureNode,
+ _gestureNode.gesture.id,
+ 3,
+ );
+ });
+
+ test('stateManager.consumeGesture should call __ConsumeGesture correctly', async () => {
+ let _panGesture;
+ const mockGestureManager = new MockGestureManager();
+
+ const App = () => {
+ const panGesture = useGesture(PanGesture);
+ panGesture.onBegin((event, stateManager) => {
+ 'main thread';
+ stateManager.consumeGesture(true);
+ });
+
+ _panGesture = panGesture;
+ return (
+
+
+
+ );
+ };
+
+ await act(() => {
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ });
+
+ await act(() => {
+ const eventObj = genEventObj(_gestureNode, {
+ params: { x: 0, y: 1 },
+ });
+
+ triggerGestureCallback(
+ _gestureNode,
+ 'onBegin',
+ eventObj,
+ mockGestureManager,
+ );
+ });
+
+ expect(mockGestureManager.__ConsumeGesture).toBeCalledWith(
+ _gestureNode,
+ _gestureNode.gesture.id,
+ { consume: true, inner: true },
+ );
+ });
+
+ test('stateManager.interceptGesture should call __ConsumeGesture correctly', async () => {
+ let _panGesture;
+ const mockGestureManager = new MockGestureManager();
+
+ const App = () => {
+ const panGesture = useGesture(PanGesture);
+ panGesture.onBegin((event, stateManager) => {
+ 'main thread';
+ stateManager.interceptGesture(true);
+ });
+
+ _panGesture = panGesture;
+ return (
+
+
+
+ );
+ };
+
+ await act(() => {
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ });
+
+ await act(() => {
+ const eventObj = genEventObj(_gestureNode, {
+ params: { x: 0, y: 1 },
+ });
+
+ triggerGestureCallback(
+ _gestureNode,
+ 'onBegin',
+ eventObj,
+ mockGestureManager,
+ );
+ });
+
+ expect(mockGestureManager.__ConsumeGesture).toBeCalledWith(
+ _gestureNode,
+ _gestureNode.gesture.id,
+ { consume: true, inner: false },
+ );
+ });
+});
diff --git a/packages/lynx/gesture-runtime/__test__/gesture-clone.test.ts b/packages/lynx/gesture-runtime/__test__/gesture-clone.test.ts
new file mode 100644
index 0000000000..93954072c6
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/gesture-clone.test.ts
@@ -0,0 +1,250 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { describe, expect, test } from 'vitest';
+
+import {
+ DefaultScrollGesture,
+ FlingGesture,
+ LongPressGesture,
+ PanGesture,
+ TapGesture,
+} from '../src/index.js';
+
+const MainThreadFunction = {
+ _wkltId: 'test:clone',
+};
+
+describe('Gesture Cloning', () => {
+ describe('Clone Configuration', () => {
+ test('should clone PanGesture with config', () => {
+ const original = new PanGesture();
+ original.minDistance(50);
+ original.enabled(false);
+
+ const cloned = original.clone();
+
+ expect(cloned.config.minDistance).toBe(50);
+ expect(cloned.config.enabled).toBe(false);
+ expect(cloned.id).toBe(original.id);
+ expect(cloned.execId).toBe(original.execId);
+ });
+
+ test('should clone TapGesture with config', () => {
+ const original = new TapGesture();
+ original.maxDuration(300);
+ original.maxDistance(15);
+ original.numberOfTaps(2);
+
+ const cloned = original.clone();
+
+ expect(cloned.config.maxDuration).toBe(300);
+ expect(cloned.config.maxDistance).toBe(15);
+ expect(cloned.config.numberOfTaps).toBe(2);
+ });
+
+ test('should clone LongPressGesture with config', () => {
+ const original = new LongPressGesture();
+ original.minDuration(800);
+ original.maxDistance(5);
+
+ const cloned = original.clone();
+
+ expect(cloned.config.minDuration).toBe(800);
+ expect(cloned.config.maxDistance).toBe(5);
+ });
+
+ test('should clone DefaultScrollGesture with config', () => {
+ const original = new DefaultScrollGesture();
+ original.tapSlop(7);
+
+ const cloned = original.clone();
+
+ expect(cloned.config.tapSlop).toBe(7);
+ });
+ });
+
+ describe('Clone Callbacks', () => {
+ test('should clone gesture with callbacks', () => {
+ const original = new PanGesture();
+ // @ts-expect-error Testing runtime behavior
+ original.onUpdate(MainThreadFunction);
+ // @ts-expect-error Testing runtime behavior
+ original.onBegin(MainThreadFunction);
+
+ const cloned = original.clone();
+
+ expect(cloned.callbacks.onUpdate).toBeDefined();
+ expect(cloned.callbacks.onBegin).toBeDefined();
+ });
+
+ test('should clone all callback types', () => {
+ const original = new TapGesture();
+ // @ts-expect-error Testing runtime behavior
+ original.onBegin(MainThreadFunction);
+ // @ts-expect-error Testing runtime behavior
+ original.onStart(MainThreadFunction);
+ // @ts-expect-error Testing runtime behavior
+ original.onEnd(MainThreadFunction);
+ // @ts-expect-error Testing runtime behavior
+ original.onTouchesDown(MainThreadFunction);
+ // @ts-expect-error Testing runtime behavior
+ original.onTouchesMove(MainThreadFunction);
+ // @ts-expect-error Testing runtime behavior
+ original.onTouchesUp(MainThreadFunction);
+ // @ts-expect-error Testing runtime behavior
+ original.onTouchesCancel(MainThreadFunction);
+
+ const cloned = original.clone();
+
+ expect(cloned.callbacks.onBegin).toBeDefined();
+ expect(cloned.callbacks.onStart).toBeDefined();
+ expect(cloned.callbacks.onEnd).toBeDefined();
+ expect(cloned.callbacks.onTouchesDown).toBeDefined();
+ expect(cloned.callbacks.onTouchesMove).toBeDefined();
+ expect(cloned.callbacks.onTouchesUp).toBeDefined();
+ expect(cloned.callbacks.onTouchesCancel).toBeDefined();
+ });
+ });
+
+ describe('Clone Relationships', () => {
+ test('should clone simultaneousWith relationships', () => {
+ const gesture1 = new PanGesture();
+ const gesture2 = new TapGesture();
+
+ gesture1.externalSimultaneous(gesture2);
+ const cloned = gesture1.clone();
+
+ expect(cloned.simultaneousWith.length).toBe(
+ gesture1.simultaneousWith.length,
+ );
+ expect(cloned.simultaneousWith).toContain(gesture2);
+ });
+
+ test('should clone waitFor relationships', () => {
+ const gesture1 = new PanGesture();
+ const gesture2 = new TapGesture();
+
+ gesture1.externalWaitFor(gesture2);
+ const cloned = gesture1.clone();
+
+ expect(cloned.waitFor.length).toBe(gesture1.waitFor.length);
+ expect(cloned.waitFor).toContain(gesture2);
+ });
+
+ test('should clone continueWith relationships', () => {
+ const gesture1 = new PanGesture();
+ const gesture2 = new TapGesture();
+
+ gesture1.externalContinueWith(gesture2);
+ const cloned = gesture1.clone();
+
+ expect(cloned.continueWith.length).toBe(gesture1.continueWith.length);
+ expect(cloned.continueWith).toContain(gesture2);
+ });
+
+ test('should clone all relationships together', () => {
+ const gesture1 = new PanGesture();
+ const gesture2 = new TapGesture();
+ const gesture3 = new LongPressGesture();
+ const gesture4 = new FlingGesture();
+
+ gesture1.externalSimultaneous(gesture2);
+ gesture1.externalWaitFor(gesture3);
+ gesture1.externalContinueWith(gesture4);
+
+ const cloned = gesture1.clone();
+
+ expect(cloned.simultaneousWith).toContain(gesture2);
+ expect(cloned.waitFor).toContain(gesture3);
+ expect(cloned.continueWith).toContain(gesture4);
+ });
+ });
+
+ describe('Clone Identity', () => {
+ test('cloned gesture should have same ID', () => {
+ const original = new PanGesture();
+ const cloned = original.clone();
+
+ expect(cloned.id).toBe(original.id);
+ });
+
+ test('cloned gesture should have same execId', () => {
+ const original = new PanGesture();
+ original.minDistance(50);
+ original.enabled(false);
+
+ const cloned = original.clone();
+
+ expect(cloned.execId).toBe(original.execId);
+ });
+
+ test('cloned gesture should have same type', () => {
+ const original = new PanGesture();
+ const cloned = original.clone();
+
+ expect(cloned.type).toBe(original.type);
+ });
+
+ test('cloned gesture should be instance of same class', () => {
+ const gestures = [
+ new PanGesture(),
+ new TapGesture(),
+ new LongPressGesture(),
+ new FlingGesture(),
+ new DefaultScrollGesture(),
+ ];
+
+ gestures.forEach((original) => {
+ const cloned = original.clone();
+ expect(cloned.constructor).toBe(original.constructor);
+ });
+ });
+ });
+
+ describe('Clone Behavior', () => {
+ test('cloned gesture shares config object (shallow copy)', () => {
+ const original = new PanGesture();
+ original.minDistance(50);
+
+ const cloned = original.clone();
+
+ // Config is shallow copied, so they share the same object
+ expect(cloned.config).toBe(original.config);
+
+ // Modifying through methods affects both since they share config
+ cloned.minDistance(100);
+ expect(original.config.minDistance).toBe(100);
+ expect(cloned.config.minDistance).toBe(100);
+ });
+
+ test('cloned gesture has same execId initially', () => {
+ const original = new PanGesture();
+ original.minDistance(50);
+ const originalExecId = original.execId;
+
+ const cloned = original.clone();
+
+ // Clone has same execId as original at time of cloning
+ expect(cloned.execId).toBe(originalExecId);
+ });
+ });
+
+ describe('Clone Special Properties', () => {
+ test('should clone PanGesture distanceSet property', () => {
+ const original = new PanGesture();
+ original.minDistance(50);
+ expect(original.distanceSet).toBe(true);
+
+ const cloned = original.clone();
+ expect(cloned.distanceSet).toBe(true);
+ });
+
+ test('should clone __isGesture marker', () => {
+ const original = new PanGesture();
+ const cloned = original.clone();
+
+ expect(cloned.__isGesture).toBe(true);
+ });
+ });
+});
diff --git a/packages/lynx/gesture-runtime/__test__/gesture-composition-advanced.test.ts b/packages/lynx/gesture-runtime/__test__/gesture-composition-advanced.test.ts
new file mode 100644
index 0000000000..a7b4c43246
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/gesture-composition-advanced.test.ts
@@ -0,0 +1,274 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { describe, expect, test } from 'vitest';
+
+import {
+ Gesture,
+ GestureTypeInner,
+ LongPressGesture,
+ PanGesture,
+ TapGesture,
+} from '../src/index.js';
+import type { ComposedGesture } from '../src/index.js';
+
+describe('Advanced Gesture Composition', () => {
+ describe('Exclusive Gesture', () => {
+ test('should create exclusive gesture composition', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ const composed = Gesture.Exclusive(pan, tap) as ComposedGesture;
+
+ expect(composed.gestures.length).toBe(2);
+ expect(composed.gestures[0]).toBe(pan);
+ expect(composed.gestures[1]).toBe(tap);
+ });
+
+ test('should set waitFor relationships in order', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+ const longPress = new LongPressGesture();
+
+ Gesture.Exclusive(pan, tap, longPress);
+
+ // tap should wait for pan
+ expect(tap.waitFor).toContain(pan);
+
+ // longPress should wait for both pan and tap
+ expect(longPress.waitFor).toContain(pan);
+ expect(longPress.waitFor).toContain(tap);
+ });
+
+ test('should work with nested compositions', () => {
+ const pan1 = new PanGesture();
+ const pan2 = new PanGesture();
+ const tap = new TapGesture();
+
+ const simultaneous = Gesture.Simultaneous(pan1, pan2);
+ const exclusive = Gesture.Exclusive(simultaneous, tap) as ComposedGesture;
+
+ expect(exclusive.gestures.length).toBe(2);
+ });
+ });
+
+ describe('Race Gesture', () => {
+ test('should create race gesture composition', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ const composed = Gesture.Race(pan, tap) as ComposedGesture;
+
+ expect(composed.gestures.length).toBe(2);
+ expect(composed.gestures[0]).toBe(pan);
+ expect(composed.gestures[1]).toBe(tap);
+ });
+
+ test('should work with multiple gestures', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+ const longPress = new LongPressGesture();
+
+ const composed = Gesture.Race(pan, tap, longPress) as ComposedGesture;
+
+ expect(composed.gestures.length).toBe(3);
+ });
+ });
+
+ describe('Nested Compositions', () => {
+ test('should handle simultaneous inside exclusive', () => {
+ const pan1 = new PanGesture();
+ const pan2 = new PanGesture();
+ const tap = new TapGesture();
+
+ const simultaneous = Gesture.Simultaneous(pan1, pan2);
+ const exclusive = Gesture.Exclusive(simultaneous, tap);
+
+ expect(exclusive).toBeDefined();
+ });
+
+ test('should handle exclusive inside simultaneous', () => {
+ const pan = new PanGesture();
+ const tap1 = new TapGesture();
+ const tap2 = new TapGesture();
+
+ const exclusive = Gesture.Exclusive(tap1, tap2);
+ const simultaneous = Gesture.Simultaneous(pan, exclusive);
+
+ expect(simultaneous).toBeDefined();
+ });
+
+ test('should flatten nested compositions correctly', () => {
+ const pan1 = new PanGesture();
+ const pan2 = new PanGesture();
+ const tap = new TapGesture();
+
+ const inner = Gesture.Simultaneous(pan1, pan2);
+ const outer = Gesture.Simultaneous(inner, tap) as ComposedGesture;
+
+ const flattened = outer.toGestureArray();
+ expect(flattened.length).toBe(3);
+ });
+ });
+
+ describe('External Relationships with Compositions', () => {
+ test('should handle externalWaitFor with composed gesture', () => {
+ const pan = new PanGesture();
+ const tap1 = new TapGesture();
+ const tap2 = new TapGesture();
+
+ const composed = Gesture.Simultaneous(tap1, tap2);
+ pan.externalWaitFor(composed);
+
+ // Should not throw
+ expect(pan.waitFor.length).toBeGreaterThanOrEqual(0);
+ });
+
+ test('should handle externalSimultaneous with composed gesture', () => {
+ const pan = new PanGesture();
+ const tap1 = new TapGesture();
+ const tap2 = new TapGesture();
+
+ const composed = Gesture.Simultaneous(tap1, tap2);
+ pan.externalSimultaneous(composed);
+
+ expect(pan.simultaneousWith.length).toBeGreaterThanOrEqual(0);
+ });
+
+ test('should handle externalContinueWith with composed gesture', () => {
+ const pan = new PanGesture();
+ const tap1 = new TapGesture();
+ const tap2 = new TapGesture();
+
+ const composed = Gesture.Simultaneous(tap1, tap2);
+ pan.externalContinueWith(composed);
+
+ expect(pan.continueWith.length).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ describe('Pan Distance Processing', () => {
+ test('should only process pan distance once', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ const composed = Gesture.Simultaneous(pan, tap) as ComposedGesture;
+
+ composed.processPanDistance();
+ const firstDistance = pan.config.minDistance;
+
+ composed.processPanDistance();
+ const secondDistance = pan.config.minDistance;
+
+ expect(firstDistance).toBe(secondDistance);
+ });
+
+ test('should process pan distance with longPress', () => {
+ const pan = new PanGesture();
+ const longPress = new LongPressGesture();
+
+ const composed = Gesture.Simultaneous(pan, longPress) as ComposedGesture;
+ composed.processPanDistance();
+
+ expect(pan.config.minDistance).toBe(10); // DEFAULT_DISTANCE
+ });
+
+ test('should not override user-set pan distance', () => {
+ const pan = new PanGesture();
+ pan.minDistance(50);
+ const tap = new TapGesture();
+
+ const composed = Gesture.Simultaneous(pan, tap) as ComposedGesture;
+ composed.processPanDistance();
+
+ expect(pan.config.minDistance).toBe(50);
+ });
+ });
+
+ describe('Gesture Array Conversion', () => {
+ test('should convert simple gesture to array', () => {
+ const pan = new PanGesture();
+ const array = pan.toGestureArray();
+
+ expect(array.length).toBe(1);
+ expect(array[0]).toBe(pan);
+ });
+
+ test('should flatten composed gesture to array', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+ const longPress = new LongPressGesture();
+
+ const composed = Gesture.Simultaneous(
+ pan,
+ tap,
+ longPress,
+ ) as ComposedGesture;
+ const array = composed.toGestureArray();
+
+ expect(array.length).toBe(3);
+ expect(array).toContain(pan);
+ expect(array).toContain(tap);
+ expect(array).toContain(longPress);
+ });
+ });
+
+ describe('Serialization', () => {
+ test('should serialize composed gesture', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ const composed = Gesture.Simultaneous(pan, tap) as ComposedGesture;
+ const serialized = composed.serialize();
+
+ expect(serialized.__isSerialized).toBe(true);
+ expect(serialized.gestures).toBeDefined();
+ expect(Array.isArray(serialized.gestures)).toBe(true);
+ });
+
+ test('should serialize gesture with relationships', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ pan.externalWaitFor(tap);
+
+ const serialized = pan.serialize();
+
+ expect(serialized.waitFor).toBeDefined();
+ expect(Array.isArray(serialized.waitFor)).toBe(true);
+ });
+
+ test('should serialize composed gestures with relationships', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ const composed1 = Gesture.Simultaneous(pan, tap);
+
+ const serialized = composed1.serialize();
+
+ expect(serialized.type).toBe(GestureTypeInner.COMPOSED);
+ });
+
+ test('should use toJSON for serialization', () => {
+ const pan = new PanGesture();
+ pan.minDistance(100);
+
+ const json = pan.toJSON();
+ const serialized = pan.serialize();
+
+ expect(json).toEqual(serialized);
+ });
+ });
+
+ describe('corner cases', () => {
+ test('should not throw if gesture relation is undefined', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ delete pan.simultaneousWith;
+
+ const composed = Gesture.Simultaneous(pan, tap) as ComposedGesture;
+ expect(() => composed.serialize()).not.toThrow();
+ });
+ });
+});
diff --git a/packages/lynx/gesture-runtime/__test__/gesture-config.test.ts b/packages/lynx/gesture-runtime/__test__/gesture-config.test.ts
new file mode 100644
index 0000000000..3ca319e607
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/gesture-config.test.ts
@@ -0,0 +1,221 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { describe, expect, test } from 'vitest';
+
+import {
+ DefaultScrollGesture,
+ FlingGesture,
+ LongPressGesture,
+ PanGesture,
+ TapGesture,
+} from '../src/index.js';
+
+describe('Gesture Configuration', () => {
+ describe('PanGesture', () => {
+ test('should set minDistance', () => {
+ const pan = new PanGesture();
+ pan.minDistance(50);
+ expect(pan.config.minDistance).toBe(50);
+ });
+
+ test('should track if distance was set by user', () => {
+ const pan = new PanGesture();
+ expect(pan.distanceSet).toBe(false);
+
+ pan.minDistance(100);
+ expect(pan.distanceSet).toBe(true);
+ });
+
+ test('should reset distanceSet when minDistance is undefined', () => {
+ const pan = new PanGesture();
+ pan.minDistance(100);
+ expect(pan.distanceSet).toBe(true);
+
+ // @ts-expect-error Testing runtime behavior
+ pan.minDistance(undefined);
+ expect(pan.distanceSet).toBe(false);
+ });
+
+ test('should override default minDistance', () => {
+ const pan = new PanGesture();
+ pan.overrideDefaultMinDistance();
+ expect(pan.config.minDistance).toBe(10); // DEFAULT_DISTANCE
+ });
+
+ test('should not override user-set minDistance', () => {
+ const pan = new PanGesture();
+ pan.minDistance(50);
+ pan.overrideDefaultMinDistance();
+ expect(pan.config.minDistance).toBe(50);
+ });
+
+ test('should support method chaining', () => {
+ const pan = new PanGesture()
+ .minDistance(20)
+ .enabled(false);
+
+ expect(pan.config.minDistance).toBe(20);
+ expect(pan.config.enabled).toBe(false);
+ });
+ });
+
+ describe('TapGesture', () => {
+ test('should set maxDuration', () => {
+ const tap = new TapGesture();
+ tap.maxDuration(300);
+ expect(tap.config.maxDuration).toBe(300);
+ });
+
+ test('should set maxDistance', () => {
+ const tap = new TapGesture();
+ tap.maxDistance(15);
+ expect(tap.config.maxDistance).toBe(15);
+ });
+
+ test('should set numberOfTaps', () => {
+ const tap = new TapGesture();
+ tap.numberOfTaps(2);
+ expect(tap.config.numberOfTaps).toBe(2);
+ });
+
+ test('should support method chaining', () => {
+ const tap = new TapGesture()
+ .maxDuration(400)
+ .maxDistance(20)
+ .numberOfTaps(3);
+
+ expect(tap.config.maxDuration).toBe(400);
+ expect(tap.config.maxDistance).toBe(20);
+ expect(tap.config.numberOfTaps).toBe(3);
+ });
+
+ test('should have default values', () => {
+ const tap = new TapGesture();
+ expect(tap.config.enabled).toBe(true);
+ expect(tap.config.maxDuration).toBe(500);
+ expect(tap.config.maxDistance).toBe(10);
+ });
+ });
+
+ describe('LongPressGesture', () => {
+ test('should set minDuration', () => {
+ const longPress = new LongPressGesture();
+ longPress.minDuration(800);
+ expect(longPress.config.minDuration).toBe(800);
+ });
+
+ test('should set maxDistance', () => {
+ const longPress = new LongPressGesture();
+ longPress.maxDistance(5);
+ expect(longPress.config.maxDistance).toBe(5);
+ });
+
+ test('should support method chaining', () => {
+ const longPress = new LongPressGesture()
+ .minDuration(1000)
+ .maxDistance(8);
+
+ expect(longPress.config.minDuration).toBe(1000);
+ expect(longPress.config.maxDistance).toBe(8);
+ });
+
+ test('should have default values', () => {
+ const longPress = new LongPressGesture();
+ expect(longPress.config.enabled).toBe(true);
+ expect(longPress.config.minDuration).toBe(500);
+ expect(longPress.config.maxDistance).toBe(10);
+ });
+ });
+
+ describe('FlingGesture', () => {
+ test('should create with default config', () => {
+ const fling = new FlingGesture();
+ expect(fling.config.enabled).toBe(true);
+ });
+
+ test('should support enabled configuration', () => {
+ const fling = new FlingGesture();
+ fling.enabled(false);
+ expect(fling.config.enabled).toBe(false);
+ });
+ });
+
+ describe('DefaultScrollGesture', () => {
+ test('should set tapSlop', () => {
+ const defaultScrollGesture = new DefaultScrollGesture();
+ defaultScrollGesture.tapSlop(5);
+ expect(defaultScrollGesture.config.tapSlop).toBe(5);
+ });
+
+ test('should have default tapSlop value', () => {
+ const defaultScrollGesture = new DefaultScrollGesture();
+ expect(defaultScrollGesture.config.tapSlop).toBe(3);
+ });
+
+ test('should support method chaining', () => {
+ const defaultScrollGesture = new DefaultScrollGesture()
+ .tapSlop(7)
+ .enabled(false);
+
+ expect(defaultScrollGesture.config.tapSlop).toBe(7);
+ expect(defaultScrollGesture.config.enabled).toBe(false);
+ });
+ });
+
+ describe('Common Configuration', () => {
+ test('all gestures should support enabled()', () => {
+ const gestures = [
+ new PanGesture(),
+ new TapGesture(),
+ new LongPressGesture(),
+ new FlingGesture(),
+ new DefaultScrollGesture(),
+ ];
+
+ gestures.forEach((gesture) => {
+ gesture.enabled(false);
+ expect(gesture.config.enabled).toBe(false);
+ });
+ });
+
+ test('all gestures should have unique IDs', () => {
+ const gestures = [
+ new PanGesture(),
+ new TapGesture(),
+ new LongPressGesture(),
+ new FlingGesture(),
+ new DefaultScrollGesture(),
+ ];
+
+ const ids = gestures.map((g) => g.id);
+ const uniqueIds = new Set(ids);
+ expect(uniqueIds.size).toBe(gestures.length);
+ });
+
+ test('all gestures should start with execId 0', () => {
+ const gestures = [
+ new PanGesture(),
+ new TapGesture(),
+ new LongPressGesture(),
+ new FlingGesture(),
+ new DefaultScrollGesture(),
+ ];
+
+ gestures.forEach((gesture) => {
+ expect(gesture.execId).toBe(0);
+ });
+ });
+
+ test('execId should increment on config change', () => {
+ const pan = new PanGesture();
+ expect(pan.execId).toBe(0);
+
+ pan.minDistance(10);
+ expect(pan.execId).toBe(1);
+
+ pan.enabled(false);
+ expect(pan.execId).toBe(2);
+ });
+ });
+});
diff --git a/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts b/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts
new file mode 100644
index 0000000000..b764793f59
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts
@@ -0,0 +1,200 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { afterEach, describe, expect, test } from 'vitest';
+
+import {
+ DefaultScrollGesture,
+ FlingGesture,
+ Gesture,
+ GestureTypeInner,
+ LongPressGesture,
+ PanGesture,
+ TapGesture,
+} from '../src/index.js';
+import type { ComposedGesture } from '../src/index.js';
+import { DEFAULT_DISTANCE } from '../src/utils/const.js';
+
+const MainThreadFunction = {
+ _wkltId: 'bdd4:dd564:2',
+};
+
+describe('create gesture', () => {
+ test('gesture type', () => {
+ const panGesture = new PanGesture();
+ const flingGesture = new FlingGesture();
+ const tapGesture = new TapGesture();
+ const longPressGesture = new LongPressGesture();
+
+ expect(panGesture.type).toBe(GestureTypeInner.PAN);
+ expect(flingGesture.type).toBe(GestureTypeInner.FLING);
+ expect(tapGesture.type).toBe(GestureTypeInner.TAP);
+ expect(longPressGesture.type).toBe(GestureTypeInner.LONGPRESS);
+ });
+
+ test('gesture config', () => {
+ const panGesture = new PanGesture();
+ panGesture.minDistance(100);
+
+ expect(panGesture.config.minDistance).toBe(100);
+
+ const longPressGesture = new LongPressGesture();
+ longPressGesture.minDuration(1000);
+ longPressGesture.maxDistance(200);
+
+ expect(longPressGesture.config.minDuration).toBe(1000);
+ expect(longPressGesture.config.maxDistance).toBe(200);
+
+ const tapGesture = new TapGesture();
+ tapGesture.maxDuration(1000);
+ tapGesture.maxDistance(200);
+
+ expect(tapGesture.config.maxDuration).toBe(1000);
+ expect(tapGesture.config.maxDistance).toBe(200);
+ });
+
+ test('gesture non main thread callback', () => {
+ const panGesture = new PanGesture();
+
+ expect(() =>
+ panGesture.onUpdate((event) => {
+ // Non Main Thread Callback
+ })
+ ).toThrow(
+ `Gesture Callback Must be a Main Thread Function, check callback of onUpdate's callback`,
+ );
+ });
+
+ test('gesture callbacks should have callbacks set', () => {
+ const panGesture = new TapGesture();
+ // @ts-expect-error Expected
+ panGesture.onBegin(MainThreadFunction);
+ // @ts-expect-error Expected
+ panGesture.onEnd(MainThreadFunction);
+ // @ts-expect-error Expected
+ panGesture.onTouchesDown(MainThreadFunction);
+ // @ts-expect-error Expected
+ panGesture.onTouchesMove(MainThreadFunction);
+ // @ts-expect-error Expected
+ panGesture.onTouchesUp(MainThreadFunction);
+ // @ts-expect-error Expected
+ panGesture.onTouchesCancel(MainThreadFunction);
+
+ expect(panGesture.callbacks.onBegin).toMatchObject({
+ _c: {},
+ _wkltId: expect.any(String),
+ });
+ expect(panGesture.callbacks.onEnd).toMatchObject({
+ _c: {},
+ _wkltId: expect.any(String),
+ });
+ expect(panGesture.callbacks.onTouchesDown).toMatchObject({
+ _c: {},
+ _wkltId: expect.any(String),
+ });
+ expect(panGesture.callbacks.onTouchesMove).toMatchObject({
+ _c: {},
+ _wkltId: expect.any(String),
+ });
+ expect(panGesture.callbacks.onTouchesUp).toMatchObject({
+ _c: {},
+ _wkltId: expect.any(String),
+ });
+ expect(panGesture.callbacks.onTouchesCancel).toMatchObject({
+ _c: {},
+ _wkltId: expect.any(String),
+ });
+ });
+
+ test('clone should have equal properties', () => {
+ const defaultScrollGesture = new DefaultScrollGesture();
+ defaultScrollGesture.tapSlop(100);
+
+ const cloned = defaultScrollGesture.clone();
+
+ expect(cloned).toMatchObject(defaultScrollGesture);
+ });
+});
+
+describe('gesture composition', () => {
+ test('Simultaneous Should have gestures in simultaneousWith', () => {
+ const panGesture = new PanGesture();
+ const tapGesture = new TapGesture();
+
+ const composed = Gesture.Simultaneous(
+ panGesture,
+ tapGesture,
+ ) as ComposedGesture;
+
+ expect(composed.gestures.length).toBe(2);
+ expect(composed.gestures[0]).toBe(panGesture);
+ expect(composed.gestures[1]).toBe(tapGesture);
+
+ expect(panGesture.simultaneousWith).toContain(tapGesture);
+ expect(tapGesture.simultaneousWith).toContain(panGesture);
+ });
+
+ test('pan gesture with tap have default minDistance', () => {
+ const panGesture = new PanGesture();
+ const tapGesture = new TapGesture();
+
+ const composed = Gesture.Simultaneous(
+ panGesture,
+ tapGesture,
+ ) as ComposedGesture;
+ composed.processPanDistance();
+
+ expect(panGesture.config.minDistance).toBe(DEFAULT_DISTANCE);
+
+ const panGesture2 = new PanGesture();
+ panGesture2.minDistance(100);
+ const tapGesture2 = new TapGesture();
+
+ const composed2 = Gesture.Simultaneous(
+ panGesture2,
+ tapGesture2,
+ ) as ComposedGesture;
+ composed2.processPanDistance();
+
+ expect(panGesture2.config.minDistance).toBe(100);
+ });
+
+ test('externalWaitFor should have gestures in waitFor', () => {
+ const panGesture = new PanGesture();
+ const tapGesture = new TapGesture();
+
+ tapGesture.externalWaitFor(panGesture);
+
+ expect(tapGesture.waitFor).toContain(panGesture);
+ });
+
+ test('externalSimultaneous should have gestures in simultaneousWith', () => {
+ const panGesture = new PanGesture();
+ const tapGesture = new TapGesture();
+
+ panGesture.externalSimultaneous(tapGesture);
+
+ expect(panGesture.simultaneousWith).toContain(tapGesture);
+ });
+
+ test('externalContinueWith should have gestures in continueWith', () => {
+ const panGesture = new PanGesture();
+ const tapGesture = new TapGesture();
+
+ panGesture.externalContinueWith(tapGesture);
+
+ expect(panGesture.continueWith).toContain(tapGesture);
+ });
+
+ test('self external should being skipped', () => {
+ const panGesture = new PanGesture();
+
+ panGesture.externalContinueWith(panGesture);
+ panGesture.externalSimultaneous(panGesture);
+ panGesture.externalWaitFor(panGesture);
+
+ expect(panGesture.continueWith).not.toContain(panGesture);
+ expect(panGesture.simultaneousWith).not.toContain(panGesture);
+ expect(panGesture.waitFor).not.toContain(panGesture);
+ });
+});
diff --git a/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx b/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx
new file mode 100644
index 0000000000..0c5a468ae5
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx
@@ -0,0 +1,348 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import type { MockInstance } from 'vitest';
+
+import { useState } from '@lynx-js/react';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitSchedule,
+} from '@lynx-js/react/testing-library';
+
+import {
+ DefaultScrollGesture,
+ FlingGesture,
+ Gesture,
+ GestureTypeInner,
+ LongPressGesture,
+ PanGesture,
+ TapGesture,
+} from '../src/index.js';
+import type { ComposedGesture } from '../src/index.js';
+import { useGesture } from '../src/useGesture.js';
+import { DEFAULT_DISTANCE } from '../src/utils/const.js';
+
+const MainThreadFunction = function() {
+ 'main thread';
+};
+
+describe('create gesture', () => {
+ test('gesture type', () => {
+ const panGesture = new PanGesture();
+ const flingGesture = new FlingGesture();
+ const tapGesture = new TapGesture();
+ const longPressGesture = new LongPressGesture();
+
+ expect(panGesture.type).toBe(GestureTypeInner.PAN);
+ expect(flingGesture.type).toBe(GestureTypeInner.FLING);
+ expect(tapGesture.type).toBe(GestureTypeInner.TAP);
+ expect(longPressGesture.type).toBe(GestureTypeInner.LONGPRESS);
+ });
+
+ test('gesture config', () => {
+ const panGesture = new PanGesture();
+ panGesture.minDistance(100);
+
+ expect(panGesture.config.minDistance).toBe(100);
+
+ const longPressGesture = new LongPressGesture();
+ longPressGesture.minDuration(1000);
+ longPressGesture.maxDistance(200);
+
+ expect(longPressGesture.config.minDuration).toBe(1000);
+ expect(longPressGesture.config.maxDistance).toBe(200);
+
+ const tapGesture = new TapGesture();
+ tapGesture.maxDuration(1000);
+ tapGesture.maxDistance(200);
+
+ expect(tapGesture.config.maxDuration).toBe(1000);
+ expect(tapGesture.config.maxDistance).toBe(200);
+ });
+
+ test('gesture callback', () => {
+ const panGesture = new PanGesture();
+ panGesture.onUpdate(MainThreadFunction);
+
+ expect(panGesture.callbacks.onUpdate).toMatchObject({});
+ });
+
+ test('gesture non main thread callback', () => {
+ const panGesture = new PanGesture();
+
+ expect(() =>
+ panGesture.onUpdate((event) => {
+ // Non Main Thread Callback
+ })
+ ).toThrow(
+ `Gesture Callback Must be a Main Thread Function, check callback of onUpdate's callback`,
+ );
+ });
+
+ test('clone should have equal properties', () => {
+ const defaultScrollGesture = new DefaultScrollGesture();
+ defaultScrollGesture.tapSlop(100);
+
+ const cloned = defaultScrollGesture.clone();
+
+ expect(cloned).toMatchObject(defaultScrollGesture);
+ });
+});
+
+describe('useGesture', () => {
+ test('useGesture should create gesture instance', () => {
+ let _panGesture;
+ let _tapGesture;
+ let _flingGesture;
+ let _longPressGesture;
+
+ const App = () => {
+ const panGesture = useGesture(PanGesture);
+ const tapGesture = useGesture(TapGesture);
+ const flingGesture = useGesture(FlingGesture);
+ const longPressGesture = useGesture(LongPressGesture);
+
+ _panGesture = panGesture;
+ _tapGesture = tapGesture;
+ _flingGesture = flingGesture;
+ _longPressGesture = longPressGesture;
+ return ;
+ };
+
+ const { container } = render();
+
+ expect(_panGesture).toBeInstanceOf(PanGesture);
+ expect(_tapGesture).toBeInstanceOf(TapGesture);
+ expect(_flingGesture).toBeInstanceOf(FlingGesture);
+ expect(_longPressGesture).toBeInstanceOf(LongPressGesture);
+ });
+
+ test('useGesture should create different instance when updated', async () => {
+ let _panGesture;
+ let prevPanExecId = 0;
+ let _setCurrent;
+
+ const App = () => {
+ const panGesture = useGesture(PanGesture);
+ const [current, setCurrent] = useState(0);
+
+ _setCurrent = setCurrent;
+
+ panGesture.onBegin(() => {
+ 'main thread';
+ // empty callback
+ });
+
+ _panGesture = panGesture;
+ return ;
+ };
+
+ await act(() => {
+ const { container } = render();
+ });
+
+ prevPanExecId = _panGesture;
+
+ await act(() => {
+ _setCurrent(20);
+ });
+
+ expect(_panGesture).not.toBe(prevPanExecId);
+ });
+});
+
+describe('gestures mt', () => {
+ let spySetGesture: MockInstance;
+
+ beforeEach(() => {
+ spySetGesture = vi.spyOn(
+ lynxTestingEnv.mainThread.globalThis,
+ '__SetGestureDetector',
+ );
+ });
+
+ afterEach(() => {
+ spySetGesture.mockRestore();
+ });
+
+ test('bind gestures should call __SetGestureDetector correctly', async () => {
+ let _panGesture;
+
+ const App = () => {
+ const panGesture = useGesture(PanGesture);
+
+ _panGesture = panGesture;
+ return (
+
+
+
+ );
+ };
+
+ await act(() => {
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ });
+
+ expect(spySetGesture).toHaveBeenCalled();
+
+ const [viewElement, gestureId, gestureType, gestureConfig, relationConfig] =
+ spySetGesture.mock.calls[0];
+
+ // Assert view element properties (not the whole element)
+ expect(viewElement.getAttribute('flatten')).toBe('false');
+ expect(viewElement.getAttribute('has-react-gesture')).toBe('true');
+
+ // Assert gesture ID is a number (don't care about specific value)
+ expect(typeof gestureId).toBe('number');
+ expect(gestureId).toBeGreaterThan(0);
+
+ // Assert priority
+ expect(gestureType).toBe(GestureTypeInner.PAN);
+
+ // Assert gesture config
+ expect(gestureConfig).toEqual({
+ callbacks: [],
+ config: {
+ enabled: true,
+ minDistance: 0,
+ },
+ });
+
+ // Assert relation config
+ expect(relationConfig).toEqual({
+ continueWith: [],
+ simultaneous: [],
+ waitFor: [],
+ });
+ });
+
+ test('bind gestures should call __SetGestureDetector with relationsConfig', async () => {
+ let _panGesture;
+
+ const App = () => {
+ const panGesture = useGesture(PanGesture);
+
+ _panGesture = panGesture;
+ return (
+
+
+
+ );
+ };
+
+ await act(() => {
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ });
+
+ expect(spySetGesture).toHaveBeenCalled();
+
+ const [viewElement, gestureId, gestureType, gestureConfig, relationConfig] =
+ spySetGesture.mock.calls[0];
+
+ // Assert view element properties (not the whole element)
+ expect(viewElement.getAttribute('flatten')).toBe('false');
+ expect(viewElement.getAttribute('has-react-gesture')).toBe('true');
+
+ // Assert gesture ID is a number (don't care about specific value)
+ expect(typeof gestureId).toBe('number');
+ expect(gestureId).toBeGreaterThan(0);
+
+ // Assert priority
+ expect(gestureType).toBe(GestureTypeInner.PAN);
+
+ // Assert gesture config
+ expect(gestureConfig).toEqual({
+ callbacks: [],
+ config: {
+ enabled: true,
+ minDistance: 0,
+ },
+ });
+
+ // Assert relation config
+ expect(relationConfig).toEqual({
+ continueWith: [],
+ simultaneous: [],
+ waitFor: [],
+ });
+ });
+});
+
+describe('test processGesture in MTS', () => {
+ let spySetGesture: MockInstance;
+
+ beforeEach(() => {
+ spySetGesture = vi.spyOn(
+ lynxTestingEnv.mainThread.globalThis,
+ '__SetGestureDetector',
+ );
+ });
+
+ afterEach(() => {
+ spySetGesture.mockRestore();
+ });
+
+ test('Old ReactLynx would call __SetGestureDetector correctly', async () => {
+ let _panGesture;
+
+ const App = () => {
+ const panGesture = useGesture(PanGesture);
+
+ _panGesture = panGesture;
+ return (
+
+
+
+ );
+ };
+
+ await act(() => {
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ });
+
+ expect(spySetGesture).toHaveBeenCalled();
+
+ const [viewElement, gestureId, gestureType, gestureConfig, relationConfig] =
+ spySetGesture.mock.calls[0];
+
+ // Assert view element properties (not the whole element)
+ expect(viewElement.getAttribute('flatten')).toBe('false');
+ expect(viewElement.getAttribute('has-react-gesture')).toBe('true');
+
+ // Assert gesture ID is a number (don't care about specific value)
+ expect(typeof gestureId).toBe('number');
+ expect(gestureId).toBeGreaterThan(0);
+
+ // Assert priority
+ expect(gestureType).toBe(GestureTypeInner.PAN);
+
+ // Assert gesture config
+ expect(gestureConfig).toEqual({
+ callbacks: [],
+ config: {
+ enabled: true,
+ minDistance: 0,
+ },
+ });
+
+ // Assert relation config
+ expect(relationConfig).toEqual({
+ continueWith: [],
+ simultaneous: [],
+ waitFor: [],
+ });
+ });
+});
diff --git a/packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx b/packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx
new file mode 100644
index 0000000000..cc8a397d45
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx
@@ -0,0 +1,191 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { describe, expect, test } from 'vitest';
+
+import {
+ ComposedGesture,
+ Gesture,
+ LongPressGesture,
+ PanGesture,
+ TapGesture,
+} from '../src/index.js';
+
+describe('Gesture Relations and Edge Cases', () => {
+ test('should extend undefined relation with new gestures', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+ const longPress = new LongPressGesture();
+
+ // Create a fresh pan gesture with no existing relations
+ const freshPan = new PanGesture();
+
+ // Verify it starts with empty arrays
+ expect(freshPan.simultaneousWith).toHaveLength(0);
+ expect(freshPan.waitFor).toHaveLength(0);
+
+ // Now add relations through composition
+ const composed = new ComposedGesture(freshPan, tap, longPress);
+ composed.simultaneousWith = [pan];
+ composed.prepare();
+
+ // The fresh pan should now have the relations
+ expect(freshPan.simultaneousWith).toContain(pan);
+ });
+
+ test('should handle BaseGesture in prepareSingleGesture', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+ const fling = new PanGesture();
+
+ const composed = new ComposedGesture(pan, tap);
+
+ // Test prepareSingleGesture with BaseGesture
+ composed.prepareSingleGesture(fling, [pan, tap], []);
+
+ expect(fling.simultaneousWith).toContain(pan);
+ expect(fling.simultaneousWith).toContain(tap);
+ });
+
+ test('should handle ComposedGesture in prepareSingleGesture', () => {
+ const pan1 = new PanGesture();
+ const pan2 = new PanGesture();
+ const tap = new TapGesture();
+
+ const innerComposed = new ComposedGesture(pan1, pan2);
+ const outerComposed = new ComposedGesture(innerComposed, tap);
+
+ // Test prepareSingleGesture with ComposedGesture
+ outerComposed.prepareSingleGesture(innerComposed, [tap], []);
+
+ expect(innerComposed.simultaneousWith).toContain(tap);
+ });
+
+ test('should extend existing relations', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+ const longPress = new LongPressGesture();
+ const fling = new PanGesture();
+
+ // Add initial relation
+ pan.externalSimultaneous(tap);
+ expect(pan.simultaneousWith).toContain(tap);
+
+ // Now extend with more relations through composition
+ const composed = new ComposedGesture(pan, longPress);
+ composed.simultaneousWith = [fling];
+ composed.prepare();
+
+ // Pan should have both old and new relations
+ expect(pan.simultaneousWith).toContain(tap);
+ expect(pan.simultaneousWith).toContain(fling);
+ });
+
+ test('should handle waitFor relations extension', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+ const longPress = new LongPressGesture();
+
+ // Add initial waitFor
+ pan.externalWaitFor(tap);
+
+ // Extend through composition
+ const composed = new ComposedGesture(pan, longPress);
+ composed.waitFor = [longPress];
+ composed.prepare();
+
+ expect(pan.waitFor).toContain(tap);
+ expect(pan.waitFor).toContain(longPress);
+ });
+
+ test('should serialize composed gesture with all fields', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+ const longPress = new LongPressGesture();
+
+ const composed = Gesture.Simultaneous(pan, tap);
+
+ const serialized = composed.serialize();
+
+ expect(serialized.__isSerialized).toBe(true);
+ expect(serialized.type).toBeDefined();
+ expect(serialized.gestures).toBeDefined();
+ });
+
+ test('should handle toJSON on composed gesture', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ const composed = Gesture.Exclusive(pan, tap);
+ const json = composed.toJSON();
+
+ expect(json).toEqual(composed.serialize());
+ });
+
+ test('should flatten nested composed gestures', () => {
+ const pan1 = new PanGesture();
+ const pan2 = new PanGesture();
+ const tap1 = new TapGesture();
+ const tap2 = new TapGesture();
+
+ const inner1 = Gesture.Simultaneous(pan1, pan2);
+ const inner2 = Gesture.Simultaneous(tap1, tap2);
+ const outer = Gesture.Exclusive(inner1, inner2);
+
+ const flattened = outer.toGestureArray();
+
+ expect(flattened).toContain(pan1);
+ expect(flattened).toContain(pan2);
+ expect(flattened).toContain(tap1);
+ expect(flattened).toContain(tap2);
+ });
+
+ test('should handle processPanDistance with multiple calls', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ const composed = Gesture.Simultaneous(pan, tap) as ComposedGesture;
+
+ // First call
+ composed.processPanDistance();
+ expect(composed.panProcessed).toBe(true);
+
+ // Second call should return early
+ composed.processPanDistance();
+ expect(composed.panProcessed).toBe(true);
+ });
+
+ test('should handle empty gesture array in toGestureArray', () => {
+ const composed = new ComposedGesture();
+ const array = composed.toGestureArray();
+
+ expect(array).toHaveLength(0);
+ });
+
+ test('should handle single gesture in SimultaneousGesture', () => {
+ const pan = new PanGesture();
+ const composed = Gesture.Simultaneous(pan);
+
+ expect(composed.gestures).toHaveLength(1);
+ expect(composed.gestures[0]).toBe(pan);
+ });
+
+ test('should handle ExclusiveGesture with single gesture', () => {
+ const pan = new PanGesture();
+ const composed = Gesture.Exclusive(pan);
+
+ expect(composed.gestures).toHaveLength(1);
+ expect(pan.waitFor).toHaveLength(0);
+ });
+
+ test('should handle RaceGesture', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ const composed = Gesture.Race(pan, tap);
+
+ expect(composed.gestures).toHaveLength(2);
+ expect(composed.gestures[0]).toBe(pan);
+ expect(composed.gestures[1]).toBe(tap);
+ });
+});
diff --git a/packages/lynx/gesture-runtime/__test__/is-worklet-object.test.ts b/packages/lynx/gesture-runtime/__test__/is-worklet-object.test.ts
new file mode 100644
index 0000000000..d0c43639db
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/is-worklet-object.test.ts
@@ -0,0 +1,86 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { describe, expect, test } from 'vitest';
+
+import { isWorkletObj } from '../src/utils/isWorkletObject.js';
+
+describe('isWorkletObj', () => {
+ test('should return true in MAIN THREAD mode', () => {
+ // @ts-expect-error Testing MAIN THREAD mode
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const original = globalThis.__MAIN_THREAD__;
+ // @ts-expect-error Testing MAIN THREAD mode
+ globalThis.__MAIN_THREAD__ = true;
+
+ const result = isWorkletObj({});
+
+ // @ts-expect-error Restore
+ globalThis.__MAIN_THREAD__ = original;
+
+ expect(result).toBe(true);
+ });
+
+ test('should return true for object with _wkltId', () => {
+ const worklet = {
+ _wkltId: 'test:worklet:1',
+ };
+
+ expect(isWorkletObj(worklet)).toBe(true);
+ });
+
+ test('should return false for null', () => {
+ expect(isWorkletObj(null)).toBe(false);
+ });
+
+ test('should return false for undefined', () => {
+ expect(isWorkletObj(undefined)).toBe(false);
+ });
+
+ test('should return false for object without _wkltId', () => {
+ const notWorklet = {
+ someProperty: 'value',
+ };
+
+ expect(isWorkletObj(notWorklet)).toBe(false);
+ });
+
+ test('should return false for primitive values', () => {
+ expect(isWorkletObj(123)).toBe(false);
+ expect(isWorkletObj('string')).toBe(false);
+ expect(isWorkletObj(true)).toBe(false);
+ });
+
+ test('should return false for empty object', () => {
+ expect(isWorkletObj({})).toBe(false);
+ });
+
+ test('should return true for object with _wkltId even if it has other properties', () => {
+ const worklet = {
+ _wkltId: 'test:worklet:1',
+ _execId: 123,
+ otherProp: 'value',
+ };
+
+ expect(isWorkletObj(worklet)).toBe(true);
+ });
+
+ test('should return false for array', () => {
+ expect(isWorkletObj([])).toBe(false);
+ expect(isWorkletObj([1, 2, 3])).toBe(false);
+ });
+
+ test('should return false for function', () => {
+ const fn = () => {};
+ expect(isWorkletObj(fn)).toBe(false);
+ });
+
+ test('should handle object with _wkltId as falsy value', () => {
+ const worklet = {
+ _wkltId: '',
+ };
+
+ // Still has the property, so should return true
+ expect(isWorkletObj(worklet)).toBe(true);
+ });
+});
diff --git a/packages/lynx/gesture-runtime/__test__/type-safety.test.ts b/packages/lynx/gesture-runtime/__test__/type-safety.test.ts
new file mode 100644
index 0000000000..116d80b762
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/type-safety.test.ts
@@ -0,0 +1,98 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { describe, expect, test } from 'vitest';
+
+import {
+ DefaultScrollGesture,
+ FlingGesture,
+ LongPressGesture,
+ PanGesture,
+ TapGesture,
+} from '../src/index.js';
+import type {
+ DefaultGestureConfig,
+ FlingGestureConfig,
+ GestureConfigType,
+ GestureEventType,
+ LongPressGestureConfig,
+ PanGestureConfig,
+ TapGestureConfig,
+} from '../src/index.js';
+
+const MainThreadFunction = {
+ _wkltId: 'test:type-safety',
+};
+
+describe('Type Safety', () => {
+ test('PanGesture should have typed config', () => {
+ const pan = new PanGesture();
+ pan.minDistance(100);
+
+ // TypeScript should infer correct type
+ const config: PanGestureConfig = pan.config;
+ expect(config.minDistance).toBe(100);
+ expect(config.enabled).toBe(true);
+ });
+
+ test('TapGesture should have typed config', () => {
+ const tap = new TapGesture();
+ tap.maxDuration(1000);
+ tap.maxDistance(20);
+ tap.numberOfTaps(2);
+
+ const config: TapGestureConfig = tap.config;
+ expect(config.maxDuration).toBe(1000);
+ expect(config.maxDistance).toBe(20);
+ expect(config.numberOfTaps).toBe(2);
+ });
+
+ test('LongPressGesture should have typed config', () => {
+ const longPress = new LongPressGesture();
+ longPress.minDuration(800);
+ longPress.maxDistance(15);
+
+ const config: LongPressGestureConfig = longPress.config;
+ expect(config.minDuration).toBe(800);
+ expect(config.maxDistance).toBe(15);
+ });
+
+ test('FlingGesture should have typed config', () => {
+ const fling = new FlingGesture();
+
+ const config: FlingGestureConfig = fling.config;
+ expect(config.enabled).toBe(true);
+ });
+
+ test('DefaultScrollGesture should have typed config', () => {
+ const defaultScrollGesture = new DefaultScrollGesture();
+ defaultScrollGesture.tapSlop(5);
+
+ const config: DefaultGestureConfig = defaultScrollGesture.config;
+ expect(config.tapSlop).toBe(5);
+ });
+
+ test('Method chaining should preserve type', () => {
+ const pan = new PanGesture()
+ .minDistance(50)
+ // @ts-expect-error Testing runtime behavior
+ .onUpdate(MainThreadFunction)
+ // @ts-expect-error Testing runtime behavior
+ .onEnd(MainThreadFunction);
+
+ // Should still be PanGesture type
+ expect(pan).toBeInstanceOf(PanGesture);
+ expect(pan.config.minDistance).toBe(50);
+ });
+
+ test('Callbacks should be set correctly', () => {
+ const pan = new PanGesture();
+ // @ts-expect-error Testing runtime behavior
+ pan.onUpdate(MainThreadFunction);
+ // @ts-expect-error Testing runtime behavior
+ pan.onBegin(MainThreadFunction);
+
+ expect(pan.callbacks.onUpdate).toBeDefined();
+ expect(pan.callbacks.onBegin).toBeDefined();
+ });
+});
diff --git a/packages/lynx/gesture-runtime/__test__/utils/callback.ts b/packages/lynx/gesture-runtime/__test__/utils/callback.ts
new file mode 100644
index 0000000000..3036a7057e
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/utils/callback.ts
@@ -0,0 +1,64 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { vi } from 'vitest';
+import type { Mock } from 'vitest';
+
+import type {
+ ConsumeGestureParams,
+ SetGestureStateType,
+} from '../../src/gestureInterface.js';
+import type { GestureConfig } from '../../src/processGesture.js';
+
+export class MockGestureManager {
+ __SetGestureState: Mock<
+ (dom: any, id: number, type: SetGestureStateType) => void
+ > = vi.fn((dom: any, id: number, type: SetGestureStateType) => {});
+ __ConsumeGesture: Mock<
+ (dom: any, id: number, params: ConsumeGestureParams) => void
+ > = vi.fn((dom: any, id: number, params: ConsumeGestureParams) => {});
+}
+
+function getCallback(config: GestureConfig, method: string) {
+ if (config.callbacks) {
+ return config.callbacks.find((callback) => callback.name === method);
+ } else {
+ return undefined;
+ }
+}
+
+export function triggerGestureCallback(
+ node: any,
+ method: string,
+ event: any,
+ manager: MockGestureManager,
+): void {
+ const callbackObj = getCallback(node?.gesture?.config, method);
+ if (callbackObj) {
+ globalThis.runWorklet(callbackObj.callback, [
+ event,
+ manager,
+ ]);
+ } else {
+ throw new Error(`No gesture callback for ${method}`);
+ }
+}
+
+export function genEventObj(target: any, body: Record): {
+ target: {
+ elementRefptr: any;
+ };
+ currentTarget: {
+ elementRefptr: any;
+ };
+} {
+ return {
+ target: {
+ elementRefptr: target,
+ },
+ currentTarget: {
+ elementRefptr: target,
+ },
+ ...body,
+ };
+}
diff --git a/packages/lynx/gesture-runtime/__test__/utils/setup.ts b/packages/lynx/gesture-runtime/__test__/utils/setup.ts
new file mode 100644
index 0000000000..9ab48a2ba8
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/utils/setup.ts
@@ -0,0 +1,3 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
diff --git a/packages/lynx/gesture-runtime/package.json b/packages/lynx/gesture-runtime/package.json
new file mode 100644
index 0000000000..9c89042973
--- /dev/null
+++ b/packages/lynx/gesture-runtime/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@lynx-js/gesture-runtime",
+ "version": "2.0.0",
+ "description": "Lynx Gesture",
+ "license": "ISC",
+ "sideEffects": false,
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "files": [
+ "CHANGELOG.md",
+ "README.md",
+ "dist",
+ "src"
+ ],
+ "scripts": {
+ "build": "rslib build",
+ "dev": "rslib build --watch",
+ "test": "vitest run"
+ },
+ "devDependencies": {
+ "@lynx-js/react": "workspace:*",
+ "@lynx-js/types": "3.4.11",
+ "@testing-library/jest-dom": "^6.9.0",
+ "rsbuild-plugin-publint": "0.3.3"
+ },
+ "peerDependencies": {
+ "@lynx-js/react": "*",
+ "@lynx-js/types": "*"
+ }
+}
diff --git a/packages/lynx/gesture-runtime/rslib.config.ts b/packages/lynx/gesture-runtime/rslib.config.ts
new file mode 100644
index 0000000000..5f794e9aa6
--- /dev/null
+++ b/packages/lynx/gesture-runtime/rslib.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from '@rslib/core';
+import { pluginPublint } from 'rsbuild-plugin-publint';
+
+export default defineConfig({
+ lib: [
+ {
+ format: 'esm',
+ syntax: 'es2022',
+ dts: true,
+ bundle: false,
+ },
+ ],
+ source: {
+ tsconfigPath: './tsconfig.build.json',
+ },
+ plugins: [pluginPublint()],
+});
diff --git a/packages/lynx/gesture-runtime/src/baseGesture.ts b/packages/lynx/gesture-runtime/src/baseGesture.ts
new file mode 100644
index 0000000000..0b59a405a8
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/baseGesture.ts
@@ -0,0 +1,303 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import type {
+ BaseGestureCallbacks,
+ BaseGestureConfig,
+ ContinuousGestureCallbacks,
+ GestureCallback,
+ GestureChangeEvent,
+ GestureKind,
+ InternalStateManager,
+ StateManager as StateManagerType,
+} from './gestureInterface.js';
+import { GestureTypeInner, SetGestureStateType } from './gestureInterface.js';
+import { isWorkletObj } from './utils/isWorkletObject.js';
+import { removeUndefined } from './utils/removeUndefined.js';
+
+let gestureID = 0;
+
+type WrappedCallback = (
+ event: GestureChangeEvent,
+ nativeStateManager: InternalStateManager,
+) => void;
+
+function wrapCallback(
+ cb: GestureCallback,
+ id: number,
+ name?: keyof BaseGestureCallbacks | keyof ContinuousGestureCallbacks,
+): WrappedCallback {
+ if (cb && !isWorkletObj(cb)) {
+ throw new Error(
+ `Gesture Callback Must be a Main Thread Function, check callback of ${name}'s callback`,
+ );
+ }
+
+ /* c8 ignore next -- Never reached */
+ return (
+ event: GestureChangeEvent,
+ nativeStateManager: InternalStateManager,
+ ) => {
+ 'main thread';
+ /**
+ * This should be declared inside worklet
+ * So it can be a worklet accessible class declaration
+ */
+ class StateManager implements StateManagerType {
+ gestureId: number;
+ stateManager: InternalStateManager;
+ event: GestureChangeEvent;
+
+ constructor(
+ id: number,
+ stateManager: InternalStateManager,
+ event: GestureChangeEvent,
+ ) {
+ this.gestureId = id;
+ this.stateManager = stateManager;
+ this.event = event;
+ }
+
+ fail() {
+ const result = this.stateManager.__SetGestureState(
+ this.event.currentTarget.element,
+ this.gestureId,
+ SetGestureStateType.fail,
+ );
+ return result;
+ }
+
+ active() {
+ const result = this.stateManager.__SetGestureState(
+ this.event.currentTarget.element,
+ this.gestureId,
+ SetGestureStateType.active,
+ );
+ return result;
+ }
+
+ end() {
+ const result = this.stateManager.__SetGestureState(
+ this.event.currentTarget.element,
+ this.gestureId,
+ SetGestureStateType.end,
+ );
+ return result;
+ }
+
+ consumeGesture(shouldConsume: boolean) {
+ const result = this.stateManager.__ConsumeGesture(
+ this.event.currentTarget.element,
+ this.gestureId,
+ { consume: shouldConsume, inner: true },
+ );
+ return result;
+ }
+
+ interceptGesture(
+ shouldIntercept: boolean,
+ ) {
+ return this.stateManager.__ConsumeGesture(
+ this.event.currentTarget.element,
+ this.gestureId,
+ { consume: shouldIntercept, inner: false },
+ );
+ }
+ }
+
+ return cb(event as TEvent, new StateManager(id, nativeStateManager, event));
+ };
+}
+
+abstract class BaseGesture<
+ TConfig extends BaseGestureConfig = BaseGestureConfig,
+ TEvent extends GestureChangeEvent = GestureChangeEvent,
+> implements GestureKind {
+ abstract type: GestureTypeInner;
+ config: TConfig = {
+ enabled: true,
+ } as TConfig;
+ id: number;
+ simultaneousWith: BaseGesture[] = [];
+ waitFor: BaseGesture[] = [];
+ continueWith: BaseGesture[] = [];
+ execId: number;
+ callbacks: BaseGestureCallbacks = {};
+ __isGesture: true = true;
+
+ constructor(gesture?: BaseGesture) {
+ if (gesture) {
+ this.id = gesture.id;
+ this.execId = gesture.execId;
+ this.config = { ...gesture.config };
+ this.simultaneousWith = [...gesture.simultaneousWith];
+ this.waitFor = [...gesture.waitFor];
+ this.continueWith = [...gesture.continueWith];
+ this.callbacks = { ...gesture.callbacks };
+ } else {
+ this.id = gestureID++;
+ this.execId = 0;
+ }
+ }
+
+ updateConfig = (k: string, v: unknown): this => {
+ this.execId += 1;
+ (this.config as Record)[k] = v;
+ return this;
+ };
+
+ updateCallback = (
+ k: keyof typeof this.callbacks,
+ cb: GestureCallback,
+ ): this => {
+ this.execId += 1;
+ // Wrapped callback is compatible with GestureCallback at runtime
+ this.callbacks[k] = wrapCallback(
+ cb,
+ this.id,
+ k,
+ ) as unknown as GestureCallback;
+ return this;
+ };
+
+ enabled = (enabled: boolean): this => {
+ return this.updateConfig('enabled', enabled);
+ };
+
+ // Gesture State Callbacks
+ onBegin = (cb: GestureCallback): this => {
+ return this.updateCallback('onBegin', cb);
+ };
+
+ onStart = (cb: GestureCallback): this => {
+ return this.updateCallback('onStart', cb);
+ };
+
+ onEnd = (cb: GestureCallback): this => {
+ return this.updateCallback('onEnd', cb);
+ };
+ onTouchesDown = (cb: GestureCallback): this => {
+ return this.updateCallback('onTouchesDown', cb);
+ };
+ onTouchesMove = (cb: GestureCallback): this => {
+ return this.updateCallback('onTouchesMove', cb);
+ };
+ onTouchesUp = (cb: GestureCallback): this => {
+ return this.updateCallback('onTouchesUp', cb);
+ };
+ onTouchesCancel = (cb: GestureCallback): this => {
+ return this.updateCallback('onTouchesCancel', cb);
+ };
+
+ externalWaitFor = (gesture: GestureKind): this => {
+ if (gesture === this) {
+ return this;
+ }
+ this.execId += 1;
+ if (gesture.type === GestureTypeInner.COMPOSED) {
+ this.waitFor = this.waitFor.concat(gesture.toGestureArray());
+ } else {
+ this.waitFor.push(
+ gesture as BaseGesture,
+ );
+ }
+ return this;
+ };
+
+ externalSimultaneous = (gesture: GestureKind): this => {
+ if (gesture === this) {
+ return this;
+ }
+ this.execId += 1;
+ if (gesture.type === GestureTypeInner.COMPOSED) {
+ this.simultaneousWith = this.simultaneousWith.concat(
+ gesture.toGestureArray(),
+ );
+ } else {
+ this.simultaneousWith.push(
+ gesture as BaseGesture,
+ );
+ }
+ return this;
+ };
+
+ externalContinueWith = (gesture: GestureKind): this => {
+ if (gesture === this) {
+ return this;
+ }
+ this.execId += 1;
+ if (gesture.type === GestureTypeInner.COMPOSED) {
+ this.continueWith = this.continueWith.concat(gesture.toGestureArray());
+ } else {
+ this.continueWith.push(
+ gesture as BaseGesture,
+ );
+ }
+ return this;
+ };
+
+ toGestureArray = (): BaseGesture[] => {
+ return [
+ this as unknown as BaseGesture,
+ ];
+ };
+
+ serialize = (): Record => {
+ const result = {
+ config: this.config,
+ id: this.id,
+ type: this.type,
+ simultaneousWith: this.simultaneousWith.map((gesture) => ({
+ id: gesture.id,
+ })),
+ waitFor: this.waitFor.map((gesture) => ({ id: gesture.id })),
+ continueWith: this.continueWith.map((gesture) => ({ id: gesture.id })),
+ callbacks: this.callbacks,
+ __isSerialized: true,
+ };
+
+ return removeUndefined(result);
+ };
+
+ toJSON = (): Record => {
+ return this.serialize();
+ };
+
+ clone = (): this => {
+ // Create new instance
+ const cloned = new (this.constructor as new(gesture?: this) => this)(this);
+ Object.assign(cloned, this);
+
+ return cloned;
+ };
+}
+
+abstract class ContinuousGesture<
+ TConfig extends BaseGestureConfig = BaseGestureConfig,
+ TEvent extends GestureChangeEvent = GestureChangeEvent,
+> extends BaseGesture {
+ override callbacks:
+ & BaseGestureCallbacks
+ & ContinuousGestureCallbacks = {};
+
+ override updateCallback = (
+ k: keyof typeof this.callbacks,
+ cb: GestureCallback,
+ ): this => {
+ this.execId += 1;
+ // Wrapped callback is compatible with GestureCallback at runtime
+ this.callbacks[k] = wrapCallback(
+ cb,
+ this.id,
+ k,
+ ) as unknown as GestureCallback;
+ return this;
+ };
+
+ onUpdate = (cb: GestureCallback): this => {
+ return this.updateCallback('onUpdate', cb);
+ };
+}
+
+export { BaseGesture, ContinuousGesture };
+export type BaseGestureType = InstanceType;
diff --git a/packages/lynx/gesture-runtime/src/composition.ts b/packages/lynx/gesture-runtime/src/composition.ts
new file mode 100644
index 0000000000..291c10a383
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/composition.ts
@@ -0,0 +1,170 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { BaseGesture } from './baseGesture.js';
+import type {
+ BaseGestureConfig,
+ GestureChangeEvent,
+ GestureKind,
+} from './gestureInterface.js';
+import { GestureTypeInner } from './gestureInterface.js';
+import type { PanGesture } from './panGesture.js';
+
+function extendRelation(
+ currentRelation:
+ | BaseGesture[]
+ | undefined,
+ extendWith: BaseGesture[],
+) {
+ if (currentRelation === undefined) {
+ return [...extendWith];
+ } else {
+ return [...currentRelation, ...extendWith];
+ }
+}
+
+class ComposedGesture implements GestureKind {
+ public gestures: GestureKind[] = [];
+ simultaneousWith: BaseGesture[] = [];
+ continueWith: BaseGesture[] = [];
+ waitFor: BaseGesture[] = [];
+ type: GestureTypeInner = GestureTypeInner.COMPOSED;
+ // ComposedGesture will be flatten, and should only be processed once
+ panProcessed = false;
+ __isGesture: true = true;
+
+ constructor(...gestures: GestureKind[]) {
+ this.gestures = gestures;
+ this.prepare();
+ }
+
+ prepareSingleGesture = (
+ gesture: GestureKind,
+ simultaneousGestures: BaseGesture[],
+ waitFor: BaseGesture[],
+ ): void => {
+ if (gesture instanceof BaseGesture) {
+ gesture.simultaneousWith = extendRelation(
+ gesture.simultaneousWith,
+ simultaneousGestures,
+ );
+ gesture.waitFor = extendRelation(gesture.waitFor, waitFor);
+ } else if (gesture instanceof ComposedGesture) {
+ gesture.simultaneousWith = simultaneousGestures;
+ gesture.waitFor = waitFor;
+ gesture.prepare();
+ }
+ };
+
+ prepare(): void {
+ for (const gesture of this.gestures) {
+ this.prepareSingleGesture(gesture, this.simultaneousWith, this.waitFor);
+ }
+ }
+
+ /**
+ * If pan gesture has relationship with tap or longPress, its default minDistance should be overridden
+ */
+ processPanDistance = (): void => {
+ if (this.panProcessed) {
+ return;
+ }
+ this.panProcessed = true;
+
+ const gestures = this.gestures.flatMap((gesture) =>
+ gesture.toGestureArray()
+ );
+
+ let hasTap = false;
+ const panGestureArr: PanGesture[] = [];
+ gestures.forEach((gesture) => {
+ if (
+ gesture.type === GestureTypeInner.LONGPRESS
+ || gesture.type === GestureTypeInner.TAP
+ ) {
+ hasTap = true;
+ } else if (gesture.type === GestureTypeInner.PAN) {
+ panGestureArr.push(gesture as unknown as PanGesture);
+ }
+ });
+
+ if (hasTap) {
+ panGestureArr.forEach((gesture) => {
+ gesture.overrideDefaultMinDistance();
+ });
+ }
+ };
+
+ toGestureArray = (): BaseGesture[] => {
+ return this.gestures.flatMap((gesture) => gesture.toGestureArray());
+ };
+
+ serialize = (): Record => {
+ return {
+ type: this.type,
+ gestures: this.gestures.map((gesture) => gesture.serialize()),
+ __isSerialized: true,
+ };
+ };
+
+ toJSON = (): Record => {
+ return this.serialize();
+ };
+}
+
+class SimultaneousGesture extends ComposedGesture {
+ override prepare(): void {
+ // this piece of magic works something like this:
+ // for every gesture in the array
+ const simultaneousArrays = this.gestures.map((gesture) =>
+ // we take the array it's in
+ this.gestures
+ // and make a copy without it
+ .filter((x) => x !== gesture)
+ // then we flatmap the result to get list of raw (not composed) gestures
+ // this way we don't make the gestures simultaneous with themselves, which is
+ // important when the gesture is `ExclusiveGesture` - we don't want to make
+ // exclusive gestures simultaneous
+ .flatMap((x) => x.toGestureArray())
+ );
+
+ this.gestures.forEach((gesture, index) => {
+ if (gesture) {
+ this.prepareSingleGesture(
+ gesture,
+ /* c8 ignore next -- Defensive coding: simultaneousArrays always matches gestures length */
+ simultaneousArrays[index] ?? [],
+ this.waitFor,
+ );
+ }
+ });
+ }
+}
+
+class ExclusiveGesture extends ComposedGesture {
+ override prepare(): void {
+ // transforms the array of gestures into array of grouped raw (not composed) gestures
+ // i.e. [gesture1, gesture2, ComposedGesture(gesture3, gesture4)] -> [[gesture1], [gesture2], [gesture3, gesture4]]
+ const gestureArrays = this.gestures.map((gesture) =>
+ gesture.toGestureArray()
+ );
+
+ let requireToFail: BaseGesture[] = [];
+
+ this.gestures.forEach((gesture, index) => {
+ this.prepareSingleGesture(
+ gesture,
+ this.simultaneousWith,
+ this.waitFor.concat(requireToFail),
+ );
+
+ // every group gets to wait for all groups before it
+
+ requireToFail = requireToFail.concat(gestureArrays[index]!);
+ });
+ }
+}
+
+class RaceGesture extends ComposedGesture {}
+
+export { ComposedGesture, SimultaneousGesture, ExclusiveGesture, RaceGesture };
diff --git a/packages/lynx/gesture-runtime/src/defaultScrollGesture.ts b/packages/lynx/gesture-runtime/src/defaultScrollGesture.ts
new file mode 100644
index 0000000000..fa8eec9213
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/defaultScrollGesture.ts
@@ -0,0 +1,24 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { ContinuousGesture } from './baseGesture.js';
+import type {
+ DefaultGestureChangeEvent,
+ DefaultGestureConfig,
+} from './gestureInterface.js';
+import { GestureTypeInner } from './gestureInterface.js';
+
+export class DefaultScrollGesture
+ extends ContinuousGesture
+{
+ type: GestureTypeInner = GestureTypeInner.DEFAULT;
+
+ override config: DefaultGestureConfig = {
+ enabled: true,
+ tapSlop: 3, // default tap slop
+ };
+
+ tapSlop = (tapSlop: number): this => {
+ return this.updateConfig('tapSlop', tapSlop);
+ };
+}
diff --git a/packages/lynx/gesture-runtime/src/env_types/global.d.ts b/packages/lynx/gesture-runtime/src/env_types/global.d.ts
new file mode 100644
index 0000000000..788117d50b
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/env_types/global.d.ts
@@ -0,0 +1,30 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+
+declare class FiberElement {}
+
+declare function __SetGestureDetector(
+ node: FiberElement,
+ id: number,
+ type: number,
+ config: Record,
+ relationMap: Record,
+): void;
+declare function __RemoveGestureDetector(node: FiberElement, id: number): void;
+
+declare function __FlushElementTree(element?: FiberElement): void;
+
+declare global {
+ var runWorklet: (worklet: unknown, data: unknown) => void;
+}
+
+// Internal augmentation - not exported to users
+declare module '@lynx-js/types' {
+ // biome-ignore lint/style/noNamespace: Augmenting external namespace
+ namespace MainThread {
+ interface Element {
+ element: FiberElement;
+ }
+ }
+}
diff --git a/packages/lynx/gesture-runtime/src/flingGesture.ts b/packages/lynx/gesture-runtime/src/flingGesture.ts
new file mode 100644
index 0000000000..cc27690688
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/flingGesture.ts
@@ -0,0 +1,22 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { ContinuousGesture } from './baseGesture.js';
+import type {
+ FlingGestureChangeEvent,
+ FlingGestureConfig,
+} from './gestureInterface.js';
+import { GestureTypeInner } from './gestureInterface.js';
+
+export interface FlingGestureChangeEventPayload {
+ changeX: number;
+ changeY: number;
+}
+
+class FlingGesture
+ extends ContinuousGesture
+{
+ type: GestureTypeInner = GestureTypeInner.FLING;
+}
+
+export { FlingGesture };
diff --git a/packages/lynx/gesture-runtime/src/gestureInterface.ts b/packages/lynx/gesture-runtime/src/gestureInterface.ts
new file mode 100644
index 0000000000..710ed93846
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/gestureInterface.ts
@@ -0,0 +1,302 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import type { MainThread } from '@lynx-js/types';
+
+import type { BaseGesture } from './baseGesture.js';
+
+export interface GestureKindSerialized {
+ type: GestureTypeInner;
+ waitFor: number[];
+ continueWith: number[];
+ simultaneousWith: number[];
+ __isSerialized: true;
+}
+
+export interface GestureKind {
+ __isGesture: true;
+ type: GestureTypeInner;
+ waitFor: GestureKind[];
+ continueWith: GestureKind[];
+ simultaneousWith: GestureKind[];
+ toGestureArray: () => BaseGesture[];
+ serialize: () => Record;
+}
+
+/**
+ * Base gesture change event interface.
+ * All gesture-specific events extend this interface.
+ */
+export interface GestureChangeEvent {
+ target: MainThread.Element;
+ currentTarget: MainThread.Element;
+ params: {
+ pageX: number;
+ pageY: number;
+ timestamp: number;
+ type: string;
+ clientX: number;
+ y: number;
+ x: number;
+ clientY: number;
+ };
+}
+
+/**
+ * Pan gesture change event with translation and velocity information.
+ */
+export interface PanGestureChangeEvent extends GestureChangeEvent {
+ params: GestureChangeEvent['params'] & {
+ scrollX: number;
+ scrollY: number;
+ isAtStart: boolean;
+ isAtEnd: boolean;
+ };
+}
+
+/**
+ * Tap gesture change event with tap location and count information.
+ */
+export interface TapGestureChangeEvent extends GestureChangeEvent {}
+
+/**
+ * Long press gesture change event with press location and duration information.
+ */
+export interface LongPressGestureChangeEvent extends GestureChangeEvent {}
+
+/**
+ * Fling gesture change event with fling location and velocity information.
+ */
+export interface FlingGestureChangeEvent extends GestureChangeEvent {
+ params: GestureChangeEvent['params'] & {
+ scrollX: number;
+ scrollY: number;
+ deltaX: number;
+ deltaY: number;
+ isAtStart: boolean;
+ isAtEnd: boolean;
+ };
+}
+
+/**
+ * Default gesture change event.
+ * Uses the base GestureChangeEvent without additional properties.
+ */
+export interface DefaultGestureChangeEvent extends GestureChangeEvent {
+ params: GestureChangeEvent['params'] & {
+ scrollX: number;
+ scrollY: number;
+ deltaX: number;
+ deltaY: number;
+ isAtStart: boolean;
+ isAtEnd: boolean;
+ };
+}
+
+/**
+ * Native gesture change event.
+ * Uses the base GestureChangeEvent without additional properties.
+ */
+export interface NativeGestureChangeEvent extends GestureChangeEvent {
+ // Uses base event properties
+}
+
+export enum SetGestureStateType {
+ active = 1,
+ fail = 2,
+ end = 3,
+}
+
+export interface ConsumeGestureParams {
+ inner?: boolean;
+ consume?: boolean;
+}
+
+export enum LynxNativeIOSGestureRecognizer {
+ Base = 'Base',
+ Pan = 'Pan',
+ LongPress = 'LongPress',
+ Tap = 'Tap',
+ Swipe = 'Swipe',
+ ScreenEdgePan = 'ScreenEdgePan',
+ Hover = 'Hover',
+ Rotation = 'Rotation',
+ Pinch = 'Pinch',
+}
+
+export interface InterceptGestureOptions {
+ tag: number;
+ class: string;
+ type: LynxNativeIOSGestureRecognizer;
+}
+
+/**
+ * Used internally, not meant to be public
+ */
+export interface InternalStateManager {
+ active: (id: number) => void;
+ fail: (id: number) => void;
+ end: (id: number) => void;
+ scrollBy: (deltaX: number, deltaY: number) => number[];
+ __SetGestureState: (
+ dom: FiberElement,
+ id: number,
+ type: SetGestureStateType,
+ ) => void;
+ __ConsumeGesture: (
+ dom: FiberElement,
+ id: number,
+ params: ConsumeGestureParams,
+ ) => void;
+}
+
+export interface StateManager {
+ active: () => void;
+ fail: () => void;
+ end: () => void;
+ consumeGesture: (shouldConsume: boolean) => void;
+ interceptGesture: (shouldIntercept: boolean) => void;
+}
+
+export enum GestureTypeInner {
+ COMPOSED = -1,
+ PAN = 0,
+ FLING = 1,
+ DEFAULT = 2,
+ TAP = 3,
+ LONGPRESS = 4,
+ ROTATION = 5,
+ PINCH = 6,
+ NATIVE = 7,
+}
+
+/**
+ * Generic gesture callback type that receives typed events.
+ * @template TEvent - The specific gesture event type
+ */
+export type GestureCallback<
+ TEvent extends GestureChangeEvent = GestureChangeEvent,
+> = (
+ event: TEvent,
+ stateManager: StateManager,
+) => void;
+
+export type GestureControlCallback = (
+ event: GestureChangeEvent,
+ stateManager: StateManager,
+) => void;
+
+export type GestureControlInternalCallback = (
+ event: GestureChangeEvent,
+ stateManager: InternalStateManager,
+) => void;
+
+/**
+ * Base configuration interface for all gestures.
+ * All gesture-specific configurations extend this interface.
+ */
+export interface BaseGestureConfig extends Record {
+ /** Whether the gesture is enabled. Defaults to true. */
+ enabled?: boolean;
+}
+
+/**
+ * Configuration interface for PanGesture.
+ * Pan gesture recognizes pan (dragging) gestures.
+ */
+export interface PanGestureConfig extends BaseGestureConfig {
+ /** Minimum distance (in points) that must be traveled before the gesture activates. Defaults to 0. */
+ minDistance?: number;
+ /** Active offset for X axis */
+ activeOffsetX?: number;
+ /** Active offset for Y axis */
+ activeOffsetY?: number;
+ /** Fail offset for X axis */
+ failOffsetX?: number;
+ /** Fail offset for Y axis */
+ failOffsetY?: number;
+}
+
+/**
+ * Configuration interface for TapGesture.
+ * Tap gesture recognizes tap gestures.
+ */
+export interface TapGestureConfig extends BaseGestureConfig {
+ /** Maximum duration (in milliseconds) for a tap. Defaults to 500ms. */
+ maxDuration?: number;
+ /** Maximum distance (in points) the touch can move and still be considered a tap. Defaults to 10. */
+ maxDistance?: number;
+ /** Number of taps required. Defaults to 1. */
+ numberOfTaps?: number;
+}
+
+/**
+ * Configuration interface for LongPressGesture.
+ * Long press gesture recognizes long press gestures.
+ */
+export interface LongPressGestureConfig extends BaseGestureConfig {
+ /** Minimum duration (in milliseconds) for a long press. Defaults to 500ms. */
+ minDuration?: number;
+ /** Maximum distance (in points) the touch can move and still be considered a long press. Defaults to 10. */
+ maxDistance?: number;
+}
+
+/**
+ * Configuration interface for FlingGesture.
+ * Fling gesture recognizes fling (swipe) gestures.
+ */
+export interface FlingGestureConfig extends BaseGestureConfig {
+ /** Direction of the fling gesture */
+ direction?: 'up' | 'down' | 'left' | 'right';
+ /** Number of pointers (fingers) required. Defaults to 1. */
+ numberOfPointers?: number;
+}
+
+/**
+ * Configuration interface for DefaultGesture.
+ * Default gesture is a basic gesture handler.
+ */
+export interface DefaultGestureConfig extends BaseGestureConfig {
+ /** Tap slop value (in points). Defaults to 3. */
+ tapSlop?: number;
+}
+
+/**
+ * Configuration interface for NativeGesture.
+ * Native gesture uses platform-specific gesture recognizers.
+ */
+export interface NativeGestureConfig extends BaseGestureConfig {
+ // Native gesture uses base config only
+}
+
+/**
+ * Generic base gesture callbacks interface.
+ * @template TEvent - The specific gesture event type
+ */
+export interface BaseGestureCallbacks<
+ TEvent extends GestureChangeEvent = GestureChangeEvent,
+> {
+ onBegin?: GestureCallback;
+ onStart?: GestureCallback;
+ onEnd?: GestureCallback;
+ onTouchesDown?: GestureCallback;
+ onTouchesMove?: GestureCallback;
+ onTouchesUp?: GestureCallback;
+ onTouchesCancel?: GestureCallback;
+}
+
+/**
+ * Generic continuous gesture callbacks interface.
+ * @template TEvent - The specific gesture event type
+ */
+export interface ContinuousGestureCallbacks<
+ TEvent extends GestureChangeEvent = GestureChangeEvent,
+> {
+ onUpdate?: GestureCallback;
+}
+
+declare module '@lynx-js/types' {
+ interface StandardProps {
+ 'main-thread:gesture'?: GestureKind;
+ }
+}
diff --git a/packages/lynx/gesture-runtime/src/index.ts b/packages/lynx/gesture-runtime/src/index.ts
new file mode 100644
index 0000000000..7f8ed132ba
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/index.ts
@@ -0,0 +1,48 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+
+import { BaseGesture } from './baseGesture.js';
+import {
+ ComposedGesture,
+ ExclusiveGesture,
+ RaceGesture,
+ SimultaneousGesture,
+} from './composition.js';
+import { DefaultScrollGesture } from './defaultScrollGesture.js';
+import { FlingGesture } from './flingGesture.js';
+import type { GestureKind } from './gestureInterface.js';
+import { LongPressGesture } from './longPressGesture.js';
+import { PanGesture } from './panGesture.js';
+import { TapGesture } from './tapGesture.js';
+import { useGesture } from './useGesture.js';
+
+const GestureEntry = {
+ Simultaneous(...gestures: GestureKind[]): GestureKind {
+ return new SimultaneousGesture(...gestures);
+ },
+
+ Race(...gestures: GestureKind[]): GestureKind {
+ return new RaceGesture(...gestures);
+ },
+
+ Exclusive(...gestures: GestureKind[]): GestureKind {
+ return new ExclusiveGesture(...gestures);
+ },
+};
+
+export { GestureEntry as Gesture };
+export * from './gestureInterface.js';
+export {
+ BaseGesture,
+ SimultaneousGesture,
+ ComposedGesture,
+ ExclusiveGesture,
+ DefaultScrollGesture,
+ FlingGesture,
+ PanGesture,
+ TapGesture,
+ LongPressGesture,
+};
+
+export { useGesture };
diff --git a/packages/lynx/gesture-runtime/src/longPressGesture.ts b/packages/lynx/gesture-runtime/src/longPressGesture.ts
new file mode 100644
index 0000000000..c7f839a548
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/longPressGesture.ts
@@ -0,0 +1,31 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { BaseGesture } from './baseGesture.js';
+import type {
+ LongPressGestureChangeEvent,
+ LongPressGestureConfig,
+} from './gestureInterface.js';
+import { GestureTypeInner } from './gestureInterface.js';
+import { DEFAULT_DISTANCE, DEFAULT_LONGPRESS_DURATION } from './utils/const.js';
+
+class LongPressGesture
+ extends BaseGesture
+{
+ override config: LongPressGestureConfig = {
+ enabled: true,
+ minDuration: DEFAULT_LONGPRESS_DURATION,
+ maxDistance: DEFAULT_DISTANCE,
+ };
+ type: GestureTypeInner = GestureTypeInner.LONGPRESS;
+
+ minDuration = (duration: number): this => {
+ return this.updateConfig('minDuration', duration);
+ };
+
+ maxDistance = (distance: number): this => {
+ return this.updateConfig('maxDistance', distance);
+ };
+}
+
+export { LongPressGesture };
diff --git a/packages/lynx/gesture-runtime/src/panGesture.ts b/packages/lynx/gesture-runtime/src/panGesture.ts
new file mode 100644
index 0000000000..486b7e0c75
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/panGesture.ts
@@ -0,0 +1,49 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { ContinuousGesture } from './baseGesture.js';
+import type {
+ PanGestureChangeEvent,
+ PanGestureConfig,
+} from './gestureInterface.js';
+import { GestureTypeInner } from './gestureInterface.js';
+import { DEFAULT_DISTANCE, DEFAULT_PAN_DISTANCE } from './utils/const.js';
+
+export interface PanGestureChangeEventPayload {
+ changeX: number;
+ changeY: number;
+}
+
+class PanGesture
+ extends ContinuousGesture
+{
+ type: GestureTypeInner = GestureTypeInner.PAN;
+ distanceSet = false;
+
+ override config: PanGestureConfig = {
+ enabled: true,
+ minDistance: DEFAULT_PAN_DISTANCE,
+ };
+
+ minDistance = (distance: number): this => {
+ // We need to know whether distance is set by user or it's default value
+ // So that we can get to know override it or not
+ if (distance === undefined) {
+ this.distanceSet = false;
+ } else {
+ this.distanceSet = true;
+ }
+ return this.updateConfig('minDistance', distance);
+ };
+
+ overrideDefaultMinDistance = (): this => {
+ if (this.distanceSet) {
+ return this;
+ } else {
+ this.updateConfig('minDistance', DEFAULT_DISTANCE);
+ }
+ return this;
+ };
+}
+
+export { PanGesture };
diff --git a/packages/lynx/gesture-runtime/src/tapGesture.ts b/packages/lynx/gesture-runtime/src/tapGesture.ts
new file mode 100644
index 0000000000..a3ce017eb4
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/tapGesture.ts
@@ -0,0 +1,33 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { BaseGesture } from './baseGesture.js';
+import type {
+ TapGestureChangeEvent,
+ TapGestureConfig,
+} from './gestureInterface.js';
+import { GestureTypeInner } from './gestureInterface.js';
+import { DEFAULT_DISTANCE, DEFAULT_LONGPRESS_DURATION } from './utils/const.js';
+
+class TapGesture extends BaseGesture {
+ type: GestureTypeInner = GestureTypeInner.TAP;
+ override config: TapGestureConfig = {
+ enabled: true,
+ maxDuration: DEFAULT_LONGPRESS_DURATION,
+ maxDistance: DEFAULT_DISTANCE,
+ };
+
+ maxDuration = (duration: number): this => {
+ return this.updateConfig('maxDuration', duration);
+ };
+
+ maxDistance = (distance: number): this => {
+ return this.updateConfig('maxDistance', distance);
+ };
+
+ numberOfTaps = (count: number): this => {
+ return this.updateConfig('numberOfTaps', count);
+ };
+}
+
+export { TapGesture };
diff --git a/packages/lynx/gesture-runtime/src/useGesture.ts b/packages/lynx/gesture-runtime/src/useGesture.ts
new file mode 100644
index 0000000000..5608dc39f5
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/useGesture.ts
@@ -0,0 +1,36 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { useRef } from '@lynx-js/react';
+
+import type { DefaultGesture } from './defaultGesture.js';
+import type { FlingGesture } from './flingGesture.js';
+import type { LongPressGesture } from './longPressGesture.js';
+import type { NativeGesture } from './nativeGesture.js';
+import type { PanGesture } from './panGesture.js';
+import type { TapGesture } from './tapGesture.js';
+
+type IBasicGestures =
+ | PanGesture
+ | FlingGesture
+ | DefaultGesture
+ | TapGesture
+ | LongPressGesture
+ | NativeGesture;
+
+function useGesture(
+ GestureConstructor: new() => T,
+): T {
+ const gestureRef = useRef(new GestureConstructor());
+ const lastExecIdRef = useRef(gestureRef.current.execId);
+
+ if (lastExecIdRef.current !== gestureRef.current.execId) {
+ lastExecIdRef.current = gestureRef.current.execId;
+ const cloned = gestureRef.current.clone() as T;
+ gestureRef.current = cloned;
+ }
+
+ return gestureRef.current;
+}
+
+export { useGesture };
diff --git a/packages/lynx/gesture-runtime/src/utils/const.ts b/packages/lynx/gesture-runtime/src/utils/const.ts
new file mode 100644
index 0000000000..d6d5d60d41
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/utils/const.ts
@@ -0,0 +1,6 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+export const DEFAULT_DISTANCE = 10;
+export const DEFAULT_PAN_DISTANCE = 0;
+export const DEFAULT_LONGPRESS_DURATION = 500;
diff --git a/packages/lynx/gesture-runtime/src/utils/isWorkletObject.ts b/packages/lynx/gesture-runtime/src/utils/isWorkletObject.ts
new file mode 100644
index 0000000000..5f629570f7
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/utils/isWorkletObject.ts
@@ -0,0 +1,14 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+
+export function isWorkletObj(obj: unknown): boolean {
+ if (__MAIN_THREAD__) {
+ return true;
+ }
+
+ if (!obj) {
+ return false;
+ }
+ return typeof obj === 'object' && '_wkltId' in obj;
+}
diff --git a/packages/lynx/gesture-runtime/src/utils/removeUndefined.ts b/packages/lynx/gesture-runtime/src/utils/removeUndefined.ts
new file mode 100644
index 0000000000..21d377ac69
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/utils/removeUndefined.ts
@@ -0,0 +1,10 @@
+// Copyright 2025 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+export function removeUndefined(
+ obj: Record,
+): Record {
+ return Object.fromEntries(
+ Object.entries(obj).filter(([_, value]) => value !== undefined),
+ );
+}
diff --git a/packages/lynx/gesture-runtime/tsconfig.build.json b/packages/lynx/gesture-runtime/tsconfig.build.json
new file mode 100644
index 0000000000..b9a1db2ffa
--- /dev/null
+++ b/packages/lynx/gesture-runtime/tsconfig.build.json
@@ -0,0 +1,20 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "stripInternal": true,
+ "target": "ESNext",
+ "lib": ["es2021"],
+ "module": "Node16",
+ "moduleResolution": "Node16",
+ "resolveJsonModule": true,
+ "composite": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "@lynx-js/react",
+ "rootDir": "src",
+ },
+ "include": ["src"],
+ "references": [
+ { "path": "../../react" },
+ ],
+}
diff --git a/packages/lynx/gesture-runtime/tsconfig.json b/packages/lynx/gesture-runtime/tsconfig.json
new file mode 100644
index 0000000000..e13dabad5d
--- /dev/null
+++ b/packages/lynx/gesture-runtime/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.build.json" },
+ { "path": "./tsconfig.test.json" },
+ ],
+}
diff --git a/packages/lynx/gesture-runtime/tsconfig.test.json b/packages/lynx/gesture-runtime/tsconfig.test.json
new file mode 100644
index 0000000000..7b11fa0bdb
--- /dev/null
+++ b/packages/lynx/gesture-runtime/tsconfig.test.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.build.json",
+ "compilerOptions": {
+ "noEmit": true
+ },
+ "include": ["src", "__test__"]
+}
diff --git a/packages/lynx/gesture-runtime/vitest.config.ts b/packages/lynx/gesture-runtime/vitest.config.ts
new file mode 100644
index 0000000000..3e8c26be58
--- /dev/null
+++ b/packages/lynx/gesture-runtime/vitest.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig, mergeConfig } from 'vitest/config';
+import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config';
+
+const defaultConfig = await createVitestConfig();
+const config = defineConfig({
+ test: {
+ name: 'lynx/gesture-runtime',
+ setupFiles: ['__test__/utils/setup.ts'],
+ coverage: {
+ include: ['src/**'],
+ },
+ include: ['__test__/**/*.test.{js,jsx,ts,tsx}'],
+ exclude: ['__test__/utils/**'],
+ },
+});
+
+export default mergeConfig(defaultConfig, config);
diff --git a/packages/lynx/tsconfig.json b/packages/lynx/tsconfig.json
new file mode 100644
index 0000000000..c8cb2008b6
--- /dev/null
+++ b/packages/lynx/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ },
+ "references": [
+ /** packages-start */
+ { "path": "./gesture-runtime/tsconfig.build.json" },
+ /** packages-end */
+ ],
+ "include": [],
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 071c5df325..e37fa0985b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -287,6 +287,21 @@ importers:
specifier: ^8.8.4
version: 8.8.5
+ packages/lynx/gesture-runtime:
+ devDependencies:
+ '@lynx-js/react':
+ specifier: workspace:*
+ version: link:../../react
+ '@lynx-js/types':
+ specifier: 3.4.11
+ version: 3.4.11
+ '@testing-library/jest-dom':
+ specifier: ^6.9.0
+ version: 6.9.1
+ rsbuild-plugin-publint:
+ specifier: 0.3.3
+ version: 0.3.3(@rsbuild/core@1.6.7)
+
packages/mcp-servers/devtool-mcp-server:
dependencies:
'@lynx-js/debug-router-connector':
@@ -408,7 +423,7 @@ importers:
version: 10.4.1
'@testing-library/jest-dom':
specifier: ^6.9.0
- version: 6.9.0
+ version: 6.9.1
packages/react/transform:
devDependencies:
@@ -766,7 +781,7 @@ importers:
version: link:../../../rspeedy/core
'@testing-library/jest-dom':
specifier: ^6.9.0
- version: 6.9.0
+ version: 6.9.1
packages/testing-library/examples/react-compiler:
dependencies:
@@ -803,7 +818,7 @@ importers:
version: 3.4.11
'@testing-library/jest-dom':
specifier: ^6.9.0
- version: 6.9.0
+ version: 6.9.1
'@types/react':
specifier: ^18.3.25
version: 18.3.25
@@ -818,7 +833,7 @@ importers:
devDependencies:
'@testing-library/jest-dom':
specifier: ^6.9.0
- version: 6.9.0
+ version: 6.9.1
'@types/jsdom':
specifier: ^21.1.7
version: 21.1.7
@@ -3741,8 +3756,8 @@ packages:
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
- '@testing-library/jest-dom@6.9.0':
- resolution: {integrity: sha512-QHdxYMJ0YPGKYofMc6zYvo7LOViVhdc6nPg/OtM2cf9MQrwEcTxFCs7d/GJ5eSyPkHzOiBkc/KfLdFJBHzldtQ==}
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
'@trysound/sax@0.2.0':
@@ -12410,7 +12425,7 @@ snapshots:
picocolors: 1.1.1
pretty-format: 27.5.1
- '@testing-library/jest-dom@6.9.0':
+ '@testing-library/jest-dom@6.9.1':
dependencies:
'@adobe/css-tools': 4.4.2
aria-query: 5.3.0
diff --git a/tsconfig.json b/tsconfig.json
index 02fbf24466..c44f6ac69b 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -124,6 +124,9 @@
},
{
"path": "./packages/third-party"
+ },
+ {
+ "path": "./packages/lynx"
}
],
"include": []
From 533b90fdf220d7fa68d80612b40203a980f134c8 Mon Sep 17 00:00:00 2001
From: f0rdream <14049186+f0rdream@users.noreply.github.com>
Date: Mon, 8 Dec 2025 16:57:02 +0800
Subject: [PATCH 2/3] chore: gesture-runtime testing
---
packages/lynx/gesture-runtime/README.md | 4 ----
.../__test__/gesture-plain.test.ts | 2 +-
.../__test__/gesture-react.test.tsx | 2 +-
.../__test__/utils/callback.ts | 11 ++++++++--
packages/lynx/gesture-runtime/package.json | 2 +-
.../lynx/gesture-runtime/src/baseGesture.ts | 2 +-
packages/lynx/gesture-runtime/turbo.jsonc | 20 +++++++++++++++++++
vitest.config.ts | 1 +
8 files changed, 34 insertions(+), 10 deletions(-)
create mode 100644 packages/lynx/gesture-runtime/turbo.jsonc
diff --git a/packages/lynx/gesture-runtime/README.md b/packages/lynx/gesture-runtime/README.md
index 02eb265321..8612208b82 100644
--- a/packages/lynx/gesture-runtime/README.md
+++ b/packages/lynx/gesture-runtime/README.md
@@ -19,7 +19,3 @@ export default function Example() {
return ;
}
```
-
-## Docs
-
-- See the dedicated guide website for full documentation and API details.
diff --git a/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts b/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts
index b764793f59..41df7b2d71 100644
--- a/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts
+++ b/packages/lynx/gesture-runtime/__test__/gesture-plain.test.ts
@@ -61,7 +61,7 @@ describe('create gesture', () => {
// Non Main Thread Callback
})
).toThrow(
- `Gesture Callback Must be a Main Thread Function, check callback of onUpdate's callback`,
+ `Gesture callback for 'onUpdate' must be a main thread function`,
);
});
diff --git a/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx b/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx
index 0c5a468ae5..69afbf5be2 100644
--- a/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx
+++ b/packages/lynx/gesture-runtime/__test__/gesture-react.test.tsx
@@ -79,7 +79,7 @@ describe('create gesture', () => {
// Non Main Thread Callback
})
).toThrow(
- `Gesture Callback Must be a Main Thread Function, check callback of onUpdate's callback`,
+ `Gesture callback for 'onUpdate' must be a main thread function`,
);
});
diff --git a/packages/lynx/gesture-runtime/__test__/utils/callback.ts b/packages/lynx/gesture-runtime/__test__/utils/callback.ts
index 3036a7057e..0c42888bb1 100644
--- a/packages/lynx/gesture-runtime/__test__/utils/callback.ts
+++ b/packages/lynx/gesture-runtime/__test__/utils/callback.ts
@@ -8,7 +8,14 @@ import type {
ConsumeGestureParams,
SetGestureStateType,
} from '../../src/gestureInterface.js';
-import type { GestureConfig } from '../../src/processGesture.js';
+
+interface ProcessesGestureConfig {
+ callbacks: {
+ name: string;
+ callback: Worklet;
+ }[];
+ config?: Record;
+}
export class MockGestureManager {
__SetGestureState: Mock<
@@ -19,7 +26,7 @@ export class MockGestureManager {
> = vi.fn((dom: any, id: number, params: ConsumeGestureParams) => {});
}
-function getCallback(config: GestureConfig, method: string) {
+function getCallback(config: ProcessesGestureConfig, method: string) {
if (config.callbacks) {
return config.callbacks.find((callback) => callback.name === method);
} else {
diff --git a/packages/lynx/gesture-runtime/package.json b/packages/lynx/gesture-runtime/package.json
index 9c89042973..ac2869004a 100644
--- a/packages/lynx/gesture-runtime/package.json
+++ b/packages/lynx/gesture-runtime/package.json
@@ -2,7 +2,7 @@
"name": "@lynx-js/gesture-runtime",
"version": "2.0.0",
"description": "Lynx Gesture",
- "license": "ISC",
+ "license": "Apache-2.0",
"sideEffects": false,
"type": "module",
"exports": {
diff --git a/packages/lynx/gesture-runtime/src/baseGesture.ts b/packages/lynx/gesture-runtime/src/baseGesture.ts
index 0b59a405a8..5c4b23b0b2 100644
--- a/packages/lynx/gesture-runtime/src/baseGesture.ts
+++ b/packages/lynx/gesture-runtime/src/baseGesture.ts
@@ -29,7 +29,7 @@ function wrapCallback(
): WrappedCallback {
if (cb && !isWorkletObj(cb)) {
throw new Error(
- `Gesture Callback Must be a Main Thread Function, check callback of ${name}'s callback`,
+ `Gesture callback for '${name}' must be a main thread function.`,
);
}
diff --git a/packages/lynx/gesture-runtime/turbo.jsonc b/packages/lynx/gesture-runtime/turbo.jsonc
new file mode 100644
index 0000000000..72c6dacfba
--- /dev/null
+++ b/packages/lynx/gesture-runtime/turbo.jsonc
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://turbo.build/schema.json",
+ "extends": ["//"],
+ "tasks": {
+ "build": {
+ "dependsOn": [
+ "@lynx-js/react#build",
+ ],
+ "inputs": [
+ "$TURBO_ROOT$/tsconfig.json",
+ "package.json",
+ "src/**",
+ "rslib.config.ts",
+ ],
+ "outputs": [
+ "dist",
+ ],
+ },
+ },
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index e8f3f0e115..98c36891b2 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -78,6 +78,7 @@ export default defineConfig({
'packages/use-sync-external-store/vitest.config.ts',
'packages/web-platform/*/vitest.config.ts',
'packages/webpack/*/vitest.config.ts',
+ 'packages/lynx/gesture-runtime/vitest.config.ts',
],
},
});
From 9e03d49b7b92ff138cf738880895d0403b42ac89 Mon Sep 17 00:00:00 2001
From: f0rdream <14049186+f0rdream@users.noreply.github.com>
Date: Thu, 11 Dec 2025 16:33:18 +0800
Subject: [PATCH 3/3] chore: resolve conflicts
---
pnpm-lock.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5904a45802..23b39b6157 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -325,7 +325,7 @@ importers:
version: 6.9.1
rsbuild-plugin-publint:
specifier: 0.3.3
- version: 0.3.3(@rsbuild/core@1.6.9)
+ version: 0.3.3(@rsbuild/core@1.6.13)
packages/mcp-servers/devtool-mcp-server:
dependencies: