Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/breezy-hounds-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lynx-js/gesture-runtime': patch
---

Optimize gesture callbacks and relationships to prevent unnecessary gesture registration and rerenders.
32 changes: 32 additions & 0 deletions packages/lynx/gesture-runtime/__test__/gesture-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
57 changes: 57 additions & 0 deletions packages/lynx/gesture-runtime/__test__/gesture-relations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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 <view></view>;
};

render(<App />);

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 <view></view>;
};

await act(() => {
render(<App />, {
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 <view></view>;
};

render(<App />);

expect(globalThis.runWorklet(memoizedFnRef, [2, 3])).toBe(5);
});
});
74 changes: 36 additions & 38 deletions packages/lynx/gesture-runtime/src/baseGesture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,20 @@ abstract class BaseGesture<
}

updateConfig = (k: string, v: unknown): this => {
this.execId += 1;
(this.config as Record<string, unknown>)[k] = v;
if ((this.config as Record<string, unknown>)[k] !== v) {
this.execId += 1;
(this.config as Record<string, unknown>)[k] = v;
}
return this;
};

updateCallback = (
k: keyof typeof this.callbacks,
cb: GestureCallback<TEvent>,
): this => {
this.execId += 1;
if (!(k in this.callbacks)) {
this.execId += 1;
}
// Wrapped callback is compatible with GestureCallback<TEvent> at runtime
this.callbacks[k] = wrapCallback<TEvent>(
cb,
Expand Down Expand Up @@ -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<BaseGestureConfig, GestureChangeEvent>,
);
const gestures = gesture.type === GestureTypeInner.COMPOSED
? gesture.toGestureArray()
: [gesture as BaseGesture<BaseGestureConfig, GestureChangeEvent>];
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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<BaseGestureConfig, GestureChangeEvent>,
);
}
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<BaseGestureConfig, GestureChangeEvent>,
);
}
return this;
return this.addRelation(gesture, 'continueWith');
};

toGestureArray = (): BaseGesture<BaseGestureConfig, GestureChangeEvent>[] => {
Expand Down Expand Up @@ -284,7 +280,9 @@ abstract class ContinuousGesture<
k: keyof typeof this.callbacks,
cb: GestureCallback<TEvent>,
): this => {
this.execId += 1;
if (!(k in this.callbacks)) {
this.execId += 1;
}
// Wrapped callback is compatible with GestureCallback<TEvent> at runtime
this.callbacks[k] = wrapCallback<TEvent>(
cb,
Expand Down
64 changes: 64 additions & 0 deletions packages/lynx/gesture-runtime/src/utils/useMainThreadMemoizedFn.ts
Original file line number Diff line number Diff line change
@@ -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<T extends noop> = (
this: ThisParameterType<T>,
...args: Parameters<T>
) => ReturnType<T>;

/**
* @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<T extends noop>(fn: T): T {
Comment thread
Yradex marked this conversation as resolved.
// Create a ref on the main thread to hold the function
const fnMTRef = useMainThreadRef<T>(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<PickFunction<T>>(() => {
/* v8 ignore next 10 */
return function(this: ThisParameterType<T>, ...args: Parameters<T>) {
'main thread';
// Call the latest function stored in the ref
const currentFn = fnMTRef.current;
if (currentFn) {
return currentFn.apply(this, args) as ReturnType<T>;
}
return undefined as ReturnType<T>;
};
}, []);

return memoizedFn as unknown as T;
}
Loading
Loading