You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 34efb3d
Browse filesBrowse the repository at this point in the historyBrowse files
[General] Link late-mounted gesture objects and skip mount reactions without external relations (#4562)
## Description
Follow-up to #4546.
A relation that holds a gesture object
(`simultaneousWithExternalGesture(gesture)`) to a gesture mounted by a
different detector in a later commit never links on native. When the
outer detector attaches, the object still has tag -1, and the mount
listener only matches ref entries, so nothing re-sends the config once
the object gets its tag. The relation stays dead until the outer
detector happens to re-render, which hides the problem.
The listener now also matches gesture objects by identity: the mount
event carries the very object stored in the relation. Composition fills
relations with sibling gesture objects, so without a guard every
composed detector would update itself on mount. The listener skips
mounts of the detector's own gestures, which requires assigning
`attachedGestures` before the mount events fire in `attachHandlers`.
Most detectors have nothing that can resolve late, yet every one of them
still walks its relations on every mount of every other gesture.
`AttachedGestureState` gets a `hasExternalRelations` flag, computed in
the attach and update microtasks. It is true only when some relation
entry is a ref or a gesture object owned by another detector; numeric
tags and composition siblings never resolve late. Detectors with the
flag unset return from the listener immediately. On a screen with 300
`LegacyPressable`s, mounting 50 more spent 38 ms in mount listeners
before and 5.5 ms after.
## Test plan
<details>
<summary>Tested on the following code:</summary>
```tsx
import React, {
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import type { GestureType } from 'react-native-gesture-handler';
import {
Gesture,
GestureDetector,
LegacyPressable,
} from 'react-native-gesture-handler';
// #4540 follow-up demo (v2 detector only, v3 has no MountRegistry).
//
// Late relations: the OUTER pan declares `simultaneousWithExternalGesture` to
// an INNER pan whose detector mounts later (toggle). Without the link the inner
// (deeper) pan wins and the outer counter never moves; with it both update.
// Row 1: relation holds the inner Gesture OBJECT (fixed by the follow-up).
// Row 2: relation holds a REF filled by `withRef` (fixed by PR #4546).
//
// Perf: N composed background detectors (LegacyPressable = Simultaneous of 3)
// plus "mount 50 more", timing the commit of the newly mounted batch.
// Tiny external store so the outer detectors never re-render: a v2 detector
// re-sends its config on every re-render, which would repair the relation in
// the same commit the inner gesture mounts and hide the bug.
type Counters = {
outerObj: number;
innerObj: number;
outerRef: number;
innerRef: number;
};
const store = {
mounted: false,
counters: {
outerObj: 0,
innerObj: 0,
outerRef: 0,
innerRef: 0,
} as Counters,
listeners: new Set<() => void>(),
subscribe: (listener: () => void) => {
store.listeners.add(listener);
return () => store.listeners.delete(listener);
},
emit: () => {
store.listeners.forEach((l) => l());
},
toggle: () => {
store.mounted = !store.mounted;
store.emit();
},
bump: (key: keyof Counters) => {
store.counters = { ...store.counters, [key]: store.counters[key] + 1 };
store.emit();
},
};
// Row 1: gesture object relation.
const innerPanObj = Gesture.Pan()
.runOnJS(true)
.onUpdate(() => store.bump('innerObj'));
const outerPanObj = Gesture.Pan()
.runOnJS(true)
.simultaneousWithExternalGesture(innerPanObj)
.onUpdate(() => store.bump('outerObj'));
// Row 2: ref relation.
const innerRefHolder: React.MutableRefObject<GestureType | undefined> = {
current: undefined,
};
const innerPanRef = Gesture.Pan()
.runOnJS(true)
.withRef(innerRefHolder)
.onUpdate(() => store.bump('innerRef'));
const outerPanRef = Gesture.Pan()
.runOnJS(true)
.simultaneousWithExternalGesture(innerRefHolder)
.onUpdate(() => store.bump('outerRef'));
function InnerSlot({ inner }: { inner: GestureType }) {
const mounted = useSyncExternalStore(store.subscribe, () => store.mounted);
return mounted ? (
<GestureDetector gesture={inner}>
<View style={styles.inner} />
</GestureDetector>
) : (
<View style={[styles.inner, styles.innerPlaceholder]} />
);
}
function CounterLabel({
prefix,
outerKey,
innerKey,
}: {
prefix: string;
outerKey: keyof Counters;
innerKey: keyof Counters;
}) {
const counters = useSyncExternalStore(store.subscribe, () => store.counters);
return (
<Text style={styles.label}>
{`${prefix} outer ${counters[outerKey]} / inner ${counters[innerKey]}`}
</Text>
);
}
function ToggleButton() {
const mounted = useSyncExternalStore(store.subscribe, () => store.mounted);
return (
<Button
title={mounted ? 'Unmount inner pans' : 'Mount inner pans'}
onPress={store.toggle}
testID="toggleInner"
/>
);
}
function LateRelations() {
return (
<View style={styles.section}>
<ToggleButton />
<View style={styles.row}>
<CounterLabel prefix="object" outerKey="outerObj" innerKey="innerObj" />
<GestureDetector gesture={outerPanObj}>
<View style={styles.outer}>
<InnerSlot inner={innerPanObj} />
</View>
</GestureDetector>
</View>
<View style={styles.row}>
<CounterLabel prefix="ref " outerKey="outerRef" innerKey="innerRef" />
<GestureDetector gesture={outerPanRef}>
<View style={styles.outer}>
<InnerSlot inner={innerPanRef} />
</View>
</GestureDetector>
</View>
<Text style={styles.hint}>
Pan the dark inner box. Both counters move only when the outer pan is
linked to the inner one.
</Text>
</View>
);
}
const BACKGROUND = 300;
const BATCH = 50;
function Batch({ onCommit }: { onCommit: () => void }) {
useLayoutEffect(onCommit, [onCommit]);
return (
<View style={styles.grid}>
{Array.from({ length: BATCH }, (_, i) => (
<LegacyPressable key={i} style={styles.cell} />
))}
</View>
);
}
function MountPerf() {
const [batches, setBatches] = useState(0);
const [lastMs, setLastMs] = useState<number | null>(null);
const start = useRef(0);
const onCommit = () => {
if (start.current !== 0) {
setLastMs(Math.round(performance.now() - start.current));
start.current = 0;
}
};
return (
<View style={styles.section}>
<Button
title={`Mount ${BATCH} more (${batches} batches)`}
onPress={() => {
start.current = performance.now();
setBatches((b) => b + 1);
}}
testID="mountBatch"
/>
<Text style={styles.label}>
{BACKGROUND} background composed detectors, last batch:{' '}
{lastMs === null ? '-' : `${lastMs} ms`}
</Text>
<View style={styles.grid}>
{Array.from({ length: BACKGROUND }, (_, i) => (
<LegacyPressable key={i} style={styles.cell} />
))}
</View>
{Array.from({ length: batches }, (_, i) => (
<Batch key={i} onCommit={onCommit} />
))}
</View>
);
}
export default function EmptyExample() {
return (
<View style={styles.container}>
<LateRelations />
<MountPerf />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 24 },
section: { gap: 8 },
row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
label: { fontVariant: ['tabular-nums'], width: 180 },
hint: { opacity: 0.6, fontSize: 12 },
outer: {
width: 120,
height: 80,
backgroundColor: '#b7d1ff',
alignItems: 'center',
justifyContent: 'center',
},
inner: { width: 70, height: 50, backgroundColor: '#1e3a8a' },
innerPlaceholder: { backgroundColor: 'transparent', borderWidth: 1 },
grid: { flexDirection: 'row', flexWrap: 'wrap' },
cell: { width: 6, height: 6, margin: 1, backgroundColor: '#ddd' },
});
```
</details>
0 commit comments