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": [