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
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { renderHook } from '@testing-library/react-native';

import { useTapGesture } from '../v3/hooks/gestures';
import type { SharedValue } from '../v3/types';

jest.mock('../handlers/gestures/reanimatedWrapper', () => {
const Reanimated = {
useHandler: jest.fn(() => ({
doDependenciesDiffer: false,
context: { lastUpdateEvent: undefined },
})),
useEvent: jest.fn(() => jest.fn()),
isSharedValue: (value: unknown): boolean =>
value !== null && typeof value === 'object' && 'value' in value,
isWorkletFunction: (value: unknown): boolean =>
typeof value === 'function' && '__workletHash' in value,
makeMutable: <T,>(value: T) => ({ value }),
runOnUI: () => jest.fn(),
runOnJS:
(fn: (...args: unknown[]) => unknown) =>
(...args: unknown[]) =>
fn(...args),
useSharedValue: <T,>(value: T) => ({ value }),
setGestureState: jest.fn(),
};

return { Reanimated };
});

const { Reanimated: MockedReanimated } = jest.requireMock(
'../handlers/gestures/reanimatedWrapper'
) as unknown as { Reanimated: { useHandler: jest.Mock } };

const lastHandlers = () =>
MockedReanimated.useHandler.mock.calls.at(-1)?.[0] as Record<string, unknown>;

const makeFakeSharedValue = (value: boolean) =>
({
value,
addListener: jest.fn(),
removeListener: jest.fn(),
modify: jest.fn(),
}) as unknown as SharedValue<boolean>;

// When `runOnJS` is statically `true`, the Reanimated event path can never
// receive events, so user callbacks must not be serialized to the UI runtime
// (in dev, Worklets freezes every plain object captured in their closures,
// silently dropping later writes). These tests guard the substitution in
// `useGestureCallbacks` and the re-registration when `runOnJS` changes.
describe('[API v3] Reanimated handler registration vs runOnJS', () => {
beforeEach(() => {
MockedReanimated.useHandler.mockClear();
});

test('registers an empty handler set when runOnJS is statically true', () => {
const onActivate = jest.fn();

renderHook(() => useTapGesture({ onActivate, runOnJS: true }));

expect(MockedReanimated.useHandler).toHaveBeenCalled();
for (const call of MockedReanimated.useHandler.mock.calls) {
expect(Object.keys(call[0] as object)).toHaveLength(0);
}
});

test('registers real callbacks when runOnJS is not set', () => {
const onActivate = jest.fn();

renderHook(() => useTapGesture({ onActivate }));

expect(lastHandlers().onActivate).toBe(onActivate);
});

test('registers real callbacks when runOnJS is false', () => {
const onActivate = jest.fn();

renderHook(() => useTapGesture({ onActivate, runOnJS: false }));

expect(lastHandlers().onActivate).toBe(onActivate);
});

test('re-registers real callbacks when runOnJS changes from true to false', () => {
const onActivate = jest.fn();

const { rerender } = renderHook(
({ runOnJS }: { runOnJS: boolean }) =>
useTapGesture({ onActivate, runOnJS }),
{ initialProps: { runOnJS: true } }
);

expect(Object.keys(lastHandlers())).toHaveLength(0);

rerender({ runOnJS: false });

expect(lastHandlers().onActivate).toBe(onActivate);
});

test('swaps back to the empty handler set when runOnJS returns to true', () => {
const onActivate = jest.fn();

const { rerender } = renderHook(
({ runOnJS }: { runOnJS: boolean }) =>
useTapGesture({ onActivate, runOnJS }),
{ initialProps: { runOnJS: false } }
);

expect(lastHandlers().onActivate).toBe(onActivate);

rerender({ runOnJS: true });

expect(Object.keys(lastHandlers())).toHaveLength(0);
});

test('keeps real callbacks when runOnJS is a SharedValue holding true', () => {
const onActivate = jest.fn();

renderHook(() =>
useTapGesture({ onActivate, runOnJS: makeFakeSharedValue(true) })
);

expect(lastHandlers().onActivate).toBe(onActivate);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
useMemoizedGestureCallbacks,
} from './utils';

const EMPTY_UI_CALLBACKS = {};

function guardJSAnimatedEvent(handler: (...args: unknown[]) => void) {
return (...args: unknown[]) => {
try {
Expand Down Expand Up @@ -51,12 +53,22 @@ export function useGestureCallbacks<
let reanimatedEventHandler;

if (!config.disableReanimated) {
// Passing callbacks to UI runtime when `runOnJS` is true would freeze
// the closure, so we pass an empty object instead.
//
// This check is true only when `runOnJS` is a constant or a React state.
// When `runOnJS` is a SharedValue, we pass original callbacks.
const uiCallbacks =
config.runOnJS === true
? (EMPTY_UI_CALLBACKS as typeof callbacks)
: callbacks;
Comment on lines +61 to +64

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// eslint-disable-next-line react-hooks/rules-of-hooks
const reanimatedHandler = Reanimated?.useHandler(callbacks);
const reanimatedHandler = Reanimated?.useHandler(uiCallbacks);
// eslint-disable-next-line react-hooks/rules-of-hooks
reanimatedEventHandler = useReanimatedEventHandler(
handlerTag,
callbacks,
uiCallbacks,
reanimatedHandler,
config.changeEventCalculator,
config.fillInDefaultValues
Expand Down
Loading