Description
useMountReactions rescans relation entries that can never match, allocating three arrays per check, on every gesture mount, for every mounted detector.
MountRegistry.addMountListener installs one listener per mounted GestureDetector, and every listener runs on every gesture mount. Each listener walks its attachedGestures and calls shouldUpdateDetector up to three times per gesture (blocksHandlers, requireToFail, simultaneousWith):
// src/handlers/gestures/GestureDetector/useMountReactions.ts
for (const tag of transformIntoHandlerTags(relation)) {
if (tag === gesture.handlerTag) {
transformIntoHandlerTags allocates on every call — toArray, then .map, then .filter:
// src/handlers/utils.ts
return handlerIDs
.map((handlerID) => handlerIDToTag[handlerID] || handlerID.current?.handlerTag || -1)
.filter((handlerTag) => handlerTag > 0);
So the work is mounting × detectors × attachedGestures × 3 calls, each allocating two arrays (three when the relation isn't already an array).
The part that makes it waste rather than cost: only a ref or a string id can resolve to a tag that was unknown when the relation was attached — which is the entire reason this listener exists. A number and a Gesture object were both resolved at attach time, and transformIntoHandlerTags maps them to -1 and filters them out. They are rescanned on every mount, forever, for a comparison that cannot match.
Impact in our app: opening a sheet of ~50 pressables costs ~800 ms on the JS thread, with a few hundred GestureDetectors mounted app-wide. Measured on a Pixel 7 Pro.
Suggested fix
Allocation-free, and it skips the entry kinds that cannot resolve late:
function shouldUpdateDetector(
relation: GestureRef[] | undefined,
gesture: { handlerTag: number }
) {
if (relation === undefined) {
return false;
}
const entries = Array.isArray(relation) ? relation : [relation];
for (const entry of entries) {
if (typeof entry === 'string') {
if (handlerIDToTag[entry] === gesture.handlerTag) {
return true;
}
} else if (
entry !== null &&
typeof entry === 'object' &&
'current' in entry &&
entry.current?.handlerTag === gesture.handlerTag
) {
return true;
}
}
return false;
}
(handlerIDToTag comes from ../../handlersRegistry rather than transformIntoHandlerTags from ../../utils.)
Happy to open a PR if this shape is agreeable.
A second, separate finding — possibly a live bug on web
On web, transformIntoHandlerTags takes a different branch and returns the .current objects rather than numeric tags:
if (Platform.OS === 'web') {
return handlerIDs.map(({ current }) => current).filter((handle) => handle);
}
shouldUpdateDetector then compares each of those to a number:
if (tag === gesture.handlerTag) {
An object is never === a number, so on web shouldUpdateDetector appears to always return false, and a detector whose relation points at a gesture mounted later would never re-attach.
We ship native and have not run this on web, so this is offered as something to check rather than as a measurement. Happy to split it into its own issue if you'd prefer — the fix is different from the one above.
Steps to reproduce
The repro is Node-only and needs no install, no device and no network:
- Download
repro.mjs from the gist below
node repro.mjs (Node ≥ 18)
transformIntoHandlerTags imports react-native and RNGestureHandlerModule, so it can't be imported in Node. It depends on only toArray, Platform.OS and handlerIDToTag, so the repro carries it verbatim from the published 3.3.0 tarball and verifies its sha256 before running — the script prints the command to re-derive that hash yourself.
It runs a correctness arm first, on purpose: a faster function that answers differently is not a fix.
Output (50 gestures mounting against 300 detectors, also in the gist):
vendored transformIntoHandlerTags matches 3.3.0 (sha256 e4c8b41f3215...)
ok undefined relation upstream=false proposed=false
ok ref hit upstream=true proposed=true
ok ref miss upstream=false proposed=false
ok number id upstream=false proposed=false
ok Gesture object upstream=false proposed=false
ok string id hit upstream=true proposed=true
ok string id miss upstream=false proposed=false
agreement: IDENTICAL on all cases
50 gestures mounting against 300 mounted detectors
UPSTREAM 4.6 ms 90,000 arrays allocated
PROPOSED 0.6 ms 0 arrays allocated
7.6x wall clock, 90,000 fewer allocations
both matched 0 / 0 detectors - the scan never had anything to find
Snack or a link to a repository that reproduces the bug
https://gist.github.com/pawarren/f276e4db6a68d89d2cbb9712536828bd
Gesture Handler version
2.32.0 (verified unchanged in 3.3.0, which the repro pins)
React Native version
0.86.3
Platforms
Android, iOS
JavaScript runtime
Hermes
Workflow
Expo managed workflow
Architecture
Fabric (New Architecture)
Build type
Debug mode and Release mode
Device
Real device
Device model
Pixel 7 Pro (where the ~800 ms was measured)
Acknowledgements
Yes
Description
useMountReactionsrescans relation entries that can never match, allocating three arrays per check, on every gesture mount, for every mounted detector.MountRegistry.addMountListenerinstalls one listener per mountedGestureDetector, and every listener runs on every gesture mount. Each listener walks itsattachedGesturesand callsshouldUpdateDetectorup to three times per gesture (blocksHandlers,requireToFail,simultaneousWith):transformIntoHandlerTagsallocates on every call —toArray, then.map, then.filter:So the work is
mounting × detectors × attachedGestures × 3calls, each allocating two arrays (three when the relation isn't already an array).The part that makes it waste rather than cost: only a ref or a string id can resolve to a tag that was unknown when the relation was attached — which is the entire reason this listener exists. A number and a
Gestureobject were both resolved at attach time, andtransformIntoHandlerTagsmaps them to-1and filters them out. They are rescanned on every mount, forever, for a comparison that cannot match.Impact in our app: opening a sheet of ~50 pressables costs ~800 ms on the JS thread, with a few hundred
GestureDetectors mounted app-wide. Measured on a Pixel 7 Pro.Suggested fix
Allocation-free, and it skips the entry kinds that cannot resolve late:
(
handlerIDToTagcomes from../../handlersRegistryrather thantransformIntoHandlerTagsfrom../../utils.)Happy to open a PR if this shape is agreeable.
A second, separate finding — possibly a live bug on web
On web,
transformIntoHandlerTagstakes a different branch and returns the.currentobjects rather than numeric tags:shouldUpdateDetectorthen compares each of those to a number:An object is never
===a number, so on webshouldUpdateDetectorappears to always returnfalse, and a detector whose relation points at a gesture mounted later would never re-attach.We ship native and have not run this on web, so this is offered as something to check rather than as a measurement. Happy to split it into its own issue if you'd prefer — the fix is different from the one above.
Steps to reproduce
The repro is Node-only and needs no install, no device and no network:
repro.mjsfrom the gist belownode repro.mjs(Node ≥ 18)transformIntoHandlerTagsimportsreact-nativeandRNGestureHandlerModule, so it can't be imported in Node. It depends on onlytoArray,Platform.OSandhandlerIDToTag, so the repro carries it verbatim from the published 3.3.0 tarball and verifies its sha256 before running — the script prints the command to re-derive that hash yourself.It runs a correctness arm first, on purpose: a faster function that answers differently is not a fix.
Output (50 gestures mounting against 300 detectors, also in the gist):
Snack or a link to a repository that reproduces the bug
https://gist.github.com/pawarren/f276e4db6a68d89d2cbb9712536828bd
Gesture Handler version
2.32.0 (verified unchanged in 3.3.0, which the repro pins)
React Native version
0.86.3
Platforms
Android, iOS
JavaScript runtime
Hermes
Workflow
Expo managed workflow
Architecture
Fabric (New Architecture)
Build type
Debug mode and Release mode
Device
Real device
Device model
Pixel 7 Pro (where the ~800 ms was measured)
Acknowledgements
Yes