diff --git a/.changeset/breezy-hounds-doubt.md b/.changeset/breezy-hounds-doubt.md
new file mode 100644
index 0000000000..b34224f8f7
--- /dev/null
+++ b/.changeset/breezy-hounds-doubt.md
@@ -0,0 +1,5 @@
+---
+'@lynx-js/gesture-runtime': patch
+---
+
+Optimize gesture callbacks and relationships to prevent unnecessary gesture registration and rerenders.
diff --git a/packages/lynx/gesture-runtime/__test__/gesture-config.test.ts b/packages/lynx/gesture-runtime/__test__/gesture-config.test.ts
index 9c32c4ad43..9ccb8d8e3e 100644
--- a/packages/lynx/gesture-runtime/__test__/gesture-config.test.ts
+++ b/packages/lynx/gesture-runtime/__test__/gesture-config.test.ts
@@ -221,5 +221,37 @@ describe('Gesture Configuration', () => {
pan.enabled(false);
expect(pan.execId).toBe(2);
});
+
+ test('execId should not increment on identical config change', () => {
+ const pan = new PanGesture();
+ expect(pan.execId).toBe(0);
+
+ pan.minDistance(10);
+ expect(pan.execId).toBe(1);
+
+ // Re-applying same config should not increment
+ pan.minDistance(10);
+ expect(pan.execId).toBe(1);
+
+ pan.enabled(false);
+ expect(pan.execId).toBe(2);
+
+ // Re-applying same config should not increment
+ pan.enabled(false);
+ expect(pan.execId).toBe(2);
+ });
+
+ test('execId should not increment when setting duplicate relationships', () => {
+ const pan1 = new PanGesture();
+ const tap1 = new TapGesture();
+ expect(pan1.execId).toBe(0);
+
+ pan1.externalSimultaneous(tap1);
+ expect(pan1.execId).toBe(1);
+
+ // Setting same relationship should not increment
+ pan1.externalSimultaneous(tap1);
+ expect(pan1.execId).toBe(1);
+ });
});
});
diff --git a/packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx b/packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx
index cc8a397d45..5788cd7aff 100644
--- a/packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx
+++ b/packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx
@@ -188,4 +188,61 @@ describe('Gesture Relations and Edge Cases', () => {
expect(composed.gestures[0]).toBe(pan);
expect(composed.gestures[1]).toBe(tap);
});
+ test('should deduplicate gestures in externalWaitFor when using ComposedGesture', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ pan.externalWaitFor(tap);
+ const initialExecId = pan.execId;
+
+ // Passing a composed gesture that contains the current gesture `pan`,
+ // an already existing gesture `tap`, and a duplicate gesture `tap` again.
+ // Also include a new gesture `longPress`.
+ const longPress = new LongPressGesture();
+ const composed = Gesture.Simultaneous(pan, tap, tap, longPress);
+
+ pan.externalWaitFor(composed);
+
+ // Only `longPress` should be added. `pan` is self, `tap` is existing, the second `tap` is duplicate.
+ expect(pan.waitFor).toHaveLength(2);
+ expect(pan.waitFor).toContain(tap);
+ expect(pan.waitFor).toContain(longPress);
+ expect(pan.execId).toBe(initialExecId + 1);
+ });
+
+ test('should deduplicate gestures in externalSimultaneous when using ComposedGesture', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ pan.externalSimultaneous(tap);
+ const initialExecId = pan.execId;
+
+ const longPress = new LongPressGesture();
+ const composed = Gesture.Race(pan, tap, tap, longPress);
+
+ pan.externalSimultaneous(composed);
+
+ expect(pan.simultaneousWith).toHaveLength(2);
+ expect(pan.simultaneousWith).toContain(tap);
+ expect(pan.simultaneousWith).toContain(longPress);
+ expect(pan.execId).toBe(initialExecId + 1);
+ });
+
+ test('should deduplicate gestures in externalContinueWith when using ComposedGesture', () => {
+ const pan = new PanGesture();
+ const tap = new TapGesture();
+
+ pan.externalContinueWith(tap);
+ const initialExecId = pan.execId;
+
+ const longPress = new LongPressGesture();
+ const composed = Gesture.Exclusive(pan, tap, tap, longPress);
+
+ pan.externalContinueWith(composed);
+
+ expect(pan.continueWith).toHaveLength(2);
+ expect(pan.continueWith).toContain(tap);
+ expect(pan.continueWith).toContain(longPress);
+ expect(pan.execId).toBe(initialExecId + 1);
+ });
});
diff --git a/packages/lynx/gesture-runtime/__test__/useMainThreadMemoizedFn.test.tsx b/packages/lynx/gesture-runtime/__test__/useMainThreadMemoizedFn.test.tsx
new file mode 100644
index 0000000000..d173463c15
--- /dev/null
+++ b/packages/lynx/gesture-runtime/__test__/useMainThreadMemoizedFn.test.tsx
@@ -0,0 +1,109 @@
+// 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 { useState } from '@lynx-js/react';
+import { act, render } from '@lynx-js/react/testing-library';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { useMainThreadMemoizedFn } from '../src/utils/useMainThreadMemoizedFn.js';
+
+describe('useMainThreadMemoizedFn', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('should return a stable reference but execute the latest logic on background thread', async () => {
+ let memoizedFnRef: any;
+ let _setCount: any;
+
+ const App = () => {
+ const [count, setCount] = useState(0);
+ _setCount = setCount;
+
+ const memoizedFn = useMainThreadMemoizedFn(() => {
+ 'main thread';
+ return count;
+ });
+
+ memoizedFnRef = memoizedFn;
+
+ return ;
+ };
+
+ render();
+
+ const fn1 = memoizedFnRef;
+ let res = globalThis.runWorklet(fn1, []);
+ expect(res).toBe(0);
+
+ await act(() => {
+ _setCount(1);
+ });
+
+ const fn2 = memoizedFnRef;
+ expect(fn1).toBe(fn2); // Stable reference!
+
+ res = globalThis.runWorklet(fn2, []);
+ expect(res).toBe(1); // Latest logic!
+ });
+
+ test('should return a stable reference but execute the latest logic on main thread', async () => {
+ let memoizedFnRef: any;
+ let _setCount: any;
+
+ const App = () => {
+ const [count, setCount] = useState(0);
+ _setCount = setCount;
+
+ const memoizedFn = useMainThreadMemoizedFn(() => {
+ 'main thread';
+ return count;
+ });
+
+ memoizedFnRef = memoizedFn;
+
+ return ;
+ };
+
+ await act(() => {
+ render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ });
+
+ const fn1 = memoizedFnRef;
+ let res = globalThis.runWorklet(fn1, []);
+ expect(res).toBe(0);
+
+ await act(() => {
+ _setCount(1);
+ });
+
+ const fn2 = memoizedFnRef;
+ expect(fn1).toBe(fn2); // Stable reference!
+
+ res = globalThis.runWorklet(fn2, []);
+ expect(res).toBe(1); // Latest logic!
+ });
+
+ test('should pass arguments properly and return value', () => {
+ let memoizedFnRef: any;
+
+ const App = () => {
+ const memoizedFn = useMainThreadMemoizedFn((a: number, b: number) => {
+ 'main thread';
+ return a + b;
+ });
+
+ memoizedFnRef = memoizedFn;
+
+ return ;
+ };
+
+ render();
+
+ expect(globalThis.runWorklet(memoizedFnRef, [2, 3])).toBe(5);
+ });
+});
diff --git a/packages/lynx/gesture-runtime/src/baseGesture.ts b/packages/lynx/gesture-runtime/src/baseGesture.ts
index 5c4b23b0b2..4ec2b5aa4d 100644
--- a/packages/lynx/gesture-runtime/src/baseGesture.ts
+++ b/packages/lynx/gesture-runtime/src/baseGesture.ts
@@ -141,8 +141,10 @@ abstract class BaseGesture<
}
updateConfig = (k: string, v: unknown): this => {
- this.execId += 1;
- (this.config as Record)[k] = v;
+ if ((this.config as Record)[k] !== v) {
+ this.execId += 1;
+ (this.config as Record)[k] = v;
+ }
return this;
};
@@ -150,7 +152,9 @@ abstract class BaseGesture<
k: keyof typeof this.callbacks,
cb: GestureCallback,
): this => {
- this.execId += 1;
+ if (!(k in this.callbacks)) {
+ this.execId += 1;
+ }
// Wrapped callback is compatible with GestureCallback at runtime
this.callbacks[k] = wrapCallback(
cb,
@@ -189,51 +193,43 @@ abstract class BaseGesture<
return this.updateCallback('onTouchesCancel', cb);
};
- externalWaitFor = (gesture: GestureKind): this => {
+ private addRelation = (
+ gesture: GestureKind,
+ relationArrayName: 'waitFor' | 'simultaneousWith' | 'continueWith',
+ ): 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,
- );
+ const gestures = gesture.type === GestureTypeInner.COMPOSED
+ ? gesture.toGestureArray()
+ : [gesture as BaseGesture];
+ const relationArray = this[relationArrayName];
+ const existingIds = new Set(relationArray.map(g => g.id));
+ const newGestures = gestures.filter(g => {
+ // Filter out self
+ if ((g as unknown) === this || g.id === this.id) return false;
+ // Filter out existing and dedupe
+ if (existingIds.has(g.id)) return false;
+ existingIds.add(g.id);
+ return true;
+ });
+ if (newGestures.length > 0) {
+ this.execId += 1;
+ this[relationArrayName] = relationArray.concat(newGestures);
}
return this;
};
+ externalWaitFor = (gesture: GestureKind): this => {
+ return this.addRelation(gesture, 'waitFor');
+ };
+
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;
+ return this.addRelation(gesture, 'simultaneousWith');
};
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;
+ return this.addRelation(gesture, 'continueWith');
};
toGestureArray = (): BaseGesture[] => {
@@ -284,7 +280,9 @@ abstract class ContinuousGesture<
k: keyof typeof this.callbacks,
cb: GestureCallback,
): this => {
- this.execId += 1;
+ if (!(k in this.callbacks)) {
+ this.execId += 1;
+ }
// Wrapped callback is compatible with GestureCallback at runtime
this.callbacks[k] = wrapCallback(
cb,
diff --git a/packages/lynx/gesture-runtime/src/utils/useMainThreadMemoizedFn.ts b/packages/lynx/gesture-runtime/src/utils/useMainThreadMemoizedFn.ts
new file mode 100644
index 0000000000..bd6282987c
--- /dev/null
+++ b/packages/lynx/gesture-runtime/src/utils/useMainThreadMemoizedFn.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 { runOnMainThread, useMainThreadRef, useMemo } from '@lynx-js/react';
+import { runWorkletCtx } from '@lynx-js/react/worklet-runtime/bindings';
+
+type noop = (this: unknown, ...args: unknown[]) => unknown;
+
+type PickFunction = (
+ this: ThisParameterType,
+ ...args: Parameters
+) => ReturnType;
+
+/**
+ * @internal
+ * Hooks for persistent main thread functions.
+ * It ensures the returned function has a stable reference while always executing the latest logic on the main thread.
+ * @example
+ * ```tsx
+ * const handleScroll = useMainThreadMemoizedFn((e: MainThread.TouchEvent) => {
+ * 'main thread';
+ * console.log(count); // Access captured variable
+ * });
+ * ```
+ */
+export function useMainThreadMemoizedFn(fn: T): T {
+ // Create a ref on the main thread to hold the function
+ const fnMTRef = useMainThreadRef(fn);
+
+ // Synchronize the latest function to the main thread ref during render
+ useMemo(() => {
+ if (__MAIN_THREAD__) {
+ /* v8 ignore next 5 */
+ // @ts-expect-error - This is a worklet context, we can directly assign to the ref
+ runWorkletCtx(() => {
+ 'main thread';
+ fnMTRef.current = fn;
+ }, []);
+ } else {
+ /* v8 ignore next 4 */
+ void runOnMainThread((latestFn: T) => {
+ 'main thread';
+ fnMTRef.current = latestFn;
+ })(fn);
+ }
+ }, [fn]);
+
+ // Return a stable wrapper function
+ const memoizedFn = useMemo>(() => {
+ /* v8 ignore next 10 */
+ return function(this: ThisParameterType, ...args: Parameters) {
+ 'main thread';
+ // Call the latest function stored in the ref
+ const currentFn = fnMTRef.current;
+ if (currentFn) {
+ return currentFn.apply(this, args) as ReturnType;
+ }
+ return undefined as ReturnType;
+ };
+ }, []);
+
+ return memoizedFn as unknown as T;
+}
diff --git a/packages/lynx/gesture-runtime/turbo.jsonc b/packages/lynx/gesture-runtime/turbo.jsonc
index 7fbaa86253..2d51bb206d 100644
--- a/packages/lynx/gesture-runtime/turbo.jsonc
+++ b/packages/lynx/gesture-runtime/turbo.jsonc
@@ -4,6 +4,7 @@
"tasks": {
"build": {
"dependsOn": [
+ "//#build",
"^build",
],
"inputs": [