Skip to content
Open
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
@@ -1,8 +1,11 @@
import { renderHook } from '@testing-library/react-native';
import type { Platform as PlatformModule } from 'react-native';

import type { GestureType } from '../handlers/gestures/gesture';
import { BaseGesture } from '../handlers/gestures/gesture';
import type { AttachedGestureState } from '../handlers/gestures/GestureDetector/types';
import { useMountReactions } from '../handlers/gestures/GestureDetector/useMountReactions';
import { hasExternalRelations } from '../handlers/gestures/GestureDetector/utils';
import { MountRegistry } from '../mountRegistry';

// The relation scan used to resolve entries through `transformIntoHandlerTags`,
Expand Down Expand Up @@ -36,13 +39,21 @@ const stateWithRelation = (
animatedHandlers: null,
shouldUseReanimated: false,
isMounted: true,
hasExternalRelations: true,
}) as unknown as AttachedGestureState;

const mount = (handlerTag: number) =>
MountRegistry.gestureHandlerWillMount({
handlerTag,
} as unknown as React.Component);

const gestureObject = (handlerTag: number, config: object = {}) => {
const gesture = Object.create(BaseGesture.prototype) as GestureType;
gesture.handlerTag = handlerTag;
gesture.config = config as GestureType['config'];
return gesture;
};

describe('useMountReactions', () => {
const relationKeys: RelationKey[] = [
'blocksHandlers',
Expand Down Expand Up @@ -97,14 +108,12 @@ describe('useMountReactions', () => {
unmount();
});

test('ignores entries that already carried their tag when the detector attached', () => {
test('ignores raw tags, which cannot change after the detector attached', () => {
const updateDetector = jest.fn();
// A gesture object and a raw tag are both resolved by the time the detector
// attaches, so mounting cannot change what they point at.
const { unmount } = renderHook(() =>
useMountReactions(
updateDetector,
stateWithRelation('simultaneousWith', [{ handlerTag: 42 }, 42])
stateWithRelation('simultaneousWith', [42])
)
);

Expand All @@ -114,6 +123,77 @@ describe('useMountReactions', () => {
unmount();
});

test('updates the detector when a gesture object in a relation mounts later', () => {
const updateDetector = jest.fn();
// The object had no tag when this detector attached; its own detector
// assigns one in `initialize` and then fires the mount event with it.
const external = gestureObject(-1);
const { unmount } = renderHook(() =>
useMountReactions(
updateDetector,
stateWithRelation('simultaneousWith', [external])
)
);

external.handlerTag = 42;
MountRegistry.gestureWillMount(external);

expect(updateDetector).toHaveBeenCalledTimes(1);
unmount();
});

test('does not match a different gesture object with the same tag', () => {
const updateDetector = jest.fn();
const { unmount } = renderHook(() =>
useMountReactions(
updateDetector,
stateWithRelation('simultaneousWith', [gestureObject(42)])
)
);

MountRegistry.gestureWillMount(gestureObject(42));

expect(updateDetector).not.toHaveBeenCalled();
unmount();
});

test('skips the mount of its own gestures', () => {
const updateDetector = jest.fn();
// Composition fills relations with sibling gestures, so without the guard
// every composed detector would update itself on mount.
const first = gestureObject(1);
const second = gestureObject(2);
first.config = { simultaneousWith: [second] } as GestureType['config'];
second.config = { simultaneousWith: [first] } as GestureType['config'];
const state = stateWithRelation('simultaneousWith', []);
state.attachedGestures = [first, second];
const { unmount } = renderHook(() =>
useMountReactions(updateDetector, state)
);

MountRegistry.gestureWillMount(first);
MountRegistry.gestureWillMount(second);

expect(updateDetector).not.toHaveBeenCalled();
unmount();
});

test('skips the scan when the detector has no external relations', () => {
const updateDetector = jest.fn();
const state = stateWithRelation('simultaneousWith', [
{ current: { handlerTag: 42 } },
]);
state.hasExternalRelations = false;
const { unmount } = renderHook(() =>
useMountReactions(updateDetector, state)
);

mount(42);

expect(updateDetector).not.toHaveBeenCalled();
unmount();
});

test('does not update a detector that is already unmounted', () => {
const updateDetector = jest.fn();
const state = stateWithRelation('simultaneousWith', [
Expand All @@ -130,3 +210,37 @@ describe('useMountReactions', () => {
unmount();
});
});

describe('hasExternalRelations', () => {
test('is false without relations or with raw tags only', () => {
expect(hasExternalRelations([gestureObject(1)])).toBe(false);
expect(
hasExternalRelations([gestureObject(1, { requireToFail: [7] })])
).toBe(false);
});

test('is false when relations only point at sibling gestures', () => {
const first = gestureObject(1);
const second = gestureObject(2);
first.config = { simultaneousWith: [second] } as GestureType['config'];
second.config = { simultaneousWith: [first] } as GestureType['config'];

expect(hasExternalRelations([first, second])).toBe(false);
});

test('is true for a ref', () => {
expect(
hasExternalRelations([
gestureObject(1, { blocksHandlers: [{ current: null }] }),
])
).toBe(true);
});

test('is true for a gesture object attached by another detector', () => {
expect(
hasExternalRelations([
gestureObject(1, { simultaneousWith: [gestureObject(2)] }),
])
).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ALLOWED_PROPS,
checkGestureCallbacksForWorklets,
extractGestureRelations,
hasExternalRelations,
} from './utils';

interface AttachHandlersConfig {
Expand Down Expand Up @@ -55,6 +56,9 @@ export function attachHandlers({
registerHandler(handler.handlerTag, handler, handler.config.testId);
}

// React to every mount until the microtask below classifies the new gesture set
preparedGesture.hasExternalRelations = true;

// Use queueMicrotask to extract handlerTags, because all refs should be initialized
// when it's ran
ghQueueMicrotask(() => {
Expand All @@ -73,9 +77,16 @@ export function attachHandlers({
);
}

preparedGesture.hasExternalRelations =
hasExternalRelations(gesturesToAttach);
Comment thread
m-bert marked this conversation as resolved.

scheduleFlushOperations();
});

// Set before the mount events below fire, so this detector's own mount
// listener can tell its gestures apart from external ones
preparedGesture.attachedGestures = gesturesToAttach;

for (const gesture of gesturesToAttach) {
const actionType = gesture.shouldUseReanimated
? ActionType.REANIMATED_WORKLET
Expand All @@ -101,8 +112,6 @@ export function attachHandlers({
MountRegistry.gestureWillMount(gesture);
}

preparedGesture.attachedGestures = gesturesToAttach;

if (preparedGesture.animatedHandlers) {
const isAnimatedGesture = (g: GestureType) => g.shouldUseReanimated;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ export const GestureDetector = (props: GestureDetectorProps) => {
animatedHandlers: null,
shouldUseReanimated: shouldUseReanimated,
isMounted: false,
// The real value is computed in the attach microtask, which runs after
// this commit's effects. Until then react to every mount, so a relation
// to a gesture mounting in the same commit is never skipped.
hasExternalRelations: true,
}).current;

const updateAttachedGestures = useDetectorUpdater(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export interface AttachedGestureState {
shouldUseReanimated: boolean;
// Whether the GestureDetector is mounted
isMounted: boolean;
// Whether any attached gesture has a relation to a gesture outside this
// detector (a ref or a gesture object), which may resolve on a later mount
hasExternalRelations: boolean;
}

export interface GestureDetectorState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ALLOWED_PROPS,
checkGestureCallbacksForWorklets,
extractGestureRelations,
hasExternalRelations,
} from './utils';

export function updateHandlers(
Expand All @@ -33,6 +34,9 @@ export function updateHandlers(
// Store attached gestures to avoid crash when gestures changed after queueing micro task
const attachedGestures = preparedGesture.attachedGestures;

// React to every mount until the microtask below classifies the new gesture set
preparedGesture.hasExternalRelations = true;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Use queueMicrotask to extract handlerTags, because when it's ran, all refs should be updated
// and handlerTags in BaseGesture references should be updated in the loop above (we need to wait
// in case of external relations)
Expand Down Expand Up @@ -78,6 +82,10 @@ export function updateHandlers(
registerHandler(handler.handlerTag, handler, handler.config.testId);
}

// Classify the new instances: their configs reference the new siblings,
// which the old attached objects would mistake for external gestures
preparedGesture.hasExternalRelations = hasExternalRelations(newGestures);

if (preparedGesture.animatedHandlers && shouldUpdateSharedValueIfUsed) {
const newHandlersValue = attachedGestures
.filter((g) => g.shouldUseReanimated) // Ignore gestures that shouldn't run on UI
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import { useEffect } from 'react';

import type { GestureMountListener } from '../../../mountRegistry';
import { MountRegistry } from '../../../mountRegistry';
import type { GestureRef } from '../gesture';
import type { AttachedGestureState } from './types';

type MountedGesture = Parameters<GestureMountListener>[0];

function shouldUpdateDetector(
relation: GestureRef[] | undefined,
gesture: { handlerTag: number }
gesture: MountedGesture
) {
if (relation === undefined) {
return false;
}

for (const entry of relation) {
// Only refs can start pointing at a different handler after the detector
// attached, because `current` is filled in when the gesture they point at
// mounts. Gestures and numeric tags already carry their tag by then, so a
// mount can never change what they resolve to and scanning them is wasted
// work on every mount of every detector.
// A gesture object gets its tag in `initialize`, when its own detector
// attaches. The mount event carries that very object, so identity is enough.
if (entry === gesture) {
return true;
}

if (entry === null || typeof entry !== 'object' || !('current' in entry)) {
continue;
}
Expand All @@ -44,6 +48,17 @@ export function useMountReactions(
return;
}

// Nothing here can resolve late, skip the scan (most detectors)
if (!state.hasExternalRelations) {
return;
}

// Own gestures are resolved by the attach microtask, and on a reattach
// they would match the sibling entries added by composition
if (state.attachedGestures.includes(gesture as never)) {
return;
}

// At this point the ref in the gesture config should be updated, so we can check if one of the gestures
// set in a relation with the gesture got mounted. If so, we need to update the detector to propagate
// the changes to the native side.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,42 @@ function extractValidHandlerTags(interactionGroup: GestureRef[] | undefined) {
);
}

// Relations that may resolve to a different handler after this detector attached:
// refs (filled when the target mounts) and gesture objects attached by another
// detector (they get a tag in `initialize`). Siblings added by composition are
// attached together with the gesture, so they never resolve late.
export function hasExternalRelations(gestures: GestureType[]) {
const ownGestures = new Set(gestures);

for (const gesture of gestures) {
const relations = [
gesture.config.blocksHandlers,
gesture.config.requireToFail,
gesture.config.simultaneousWith,
];

for (const relation of relations) {
if (relation === undefined) {
continue;
}

for (const entry of relation) {
if (typeof entry === 'number') {
continue;
}

// Anything but our own gesture object is external: a ref, or a gesture
// attached by another detector
if (!(entry instanceof BaseGesture) || !ownGestures.has(entry)) {
return true;
}
}
}
}

return false;
}

export function extractGestureRelations(gesture: GestureType) {
const requireToFail = extractValidHandlerTags(gesture.config.requireToFail);
const simultaneousWith = extractValidHandlerTags(
Expand Down
Loading