Skip to content

useMountReactions rescans relations that can never match, allocating 3 arrays per check #4540

Description

@pawarren

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:

  1. Download repro.mjs from the gist below
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Platform: AndroidThis issue is specific to AndroidPlatform: iOSThis issue is specific to iOS

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions