Skip to content

Pass empty callbacks to UI when runOnJS is true#4326

Merged
m-bert merged 2 commits into
mainfrom
@mbert/run-on-js
Jul 22, 2026
Merged

Pass empty callbacks to UI when runOnJS is true#4326
m-bert merged 2 commits into
mainfrom
@mbert/run-on-js

Conversation

@m-bert

@m-bert m-bert commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Description

Currently we call useReanimatedEventHandler with original callbacks from config. However, objects captured in the callbacks' closures will be frozen by Reanimated as they will be passed to UI, even if runOnJS is set to true. This triggers warning from worklets, as well as prevents modification of objects in closure.

[Worklets] Tried to modify key `current` of an object which has been already passed to a worklet.

To fix that, we pass empty callbacks when runOnJS is true.

Test plan

Tested on the following code:
import React, { useCallback, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { GestureDetector, usePanGesture } from 'react-native-gesture-handler';
import { useSharedValue } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

// Empirical test matrix for the `runOnJS` noop-substitution fix.
//
// Pad A - static `runOnJS: true`. Callbacks mutate a plain `session` object;
//   before the fix dev builds froze it ("0 updates in ~1.7T ms" + warnings).
//   Expected: real numbers, no `Tried to modify key` warnings.
//
// Pad B - `runOnJS` driven by React state, toggled by the button below it.
//   Callbacks report which runtime executed them. Expected: "JS runtime"
//   while ON, then after toggling OFF the real callbacks must be re-registered
//   on the UI runtime - a drag must report "UI runtime". If the lazy swap
//   were broken, the UI path would fire the registered noops and the status
//   would never update.
//
// Pad C - `runOnJS` as a SharedValue, flipped by writing `sv.value` directly
//   (no re-render). Expected: "JS runtime" while true, "UI runtime" after the
//   flip - proving the SharedValue path still eagerly registers the real
//   callbacks and toggles without React involvement.

function runtimeName() {
  'worklet';
  return (globalThis as { _WORKLET?: boolean })._WORKLET
    ? 'UI runtime'
    : 'JS runtime';
}

export default function EmptyExample() {
  // --- Pad A: static runOnJS: true, plain mutable object ---
  const [summaryA, setSummaryA] = useState('drag to measure');
  const session = useMemo(() => ({ startTime: 0, updateCount: 0 }), []);

  const panA = usePanGesture({
    onActivate: () => {
      session.startTime = Date.now();
      session.updateCount = 0;
    },
    onUpdate: () => {
      session.updateCount += 1;
    },
    onDeactivate: () => {
      const duration = Date.now() - session.startTime;
      setSummaryA(`${session.updateCount} updates in ${duration} ms`);
    },
    runOnJS: true,
  });

  // --- Pad B: runOnJS toggled through React state ---
  const [jsModeB, setJsModeB] = useState(true);
  const [statusB, setStatusB] = useState('drag to check');

  const reportB = useCallback((runtime: string) => {
    setStatusB(`activated on ${runtime}`);
  }, []);

  const panB = usePanGesture({
    onActivate: () => {
      scheduleOnRN(reportB, runtimeName());
    },
    runOnJS: jsModeB,
  });

  // --- Pad C: runOnJS as a SharedValue, flipped without a re-render ---
  const jsModeC = useSharedValue(true);
  const [statusC, setStatusC] = useState('drag to check');

  const reportC = useCallback((runtime: string) => {
    setStatusC(`activated on ${runtime}`);
  }, []);

  const panC = usePanGesture({
    onActivate: () => {
      scheduleOnRN(reportC, runtimeName());
    },
    runOnJS: jsModeC,
  });

  return (
    <View style={styles.container}>
      <GestureDetector gesture={panA}>
        <View style={styles.pad}>
          <Text style={styles.padLabel}>Pad A - static true</Text>
        </View>
      </GestureDetector>
      <Text style={styles.status}>{summaryA}</Text>

      <GestureDetector gesture={panB}>
        <View style={styles.pad}>
          <Text style={styles.padLabel}>Pad B - state toggle</Text>
        </View>
      </GestureDetector>
      <Text style={styles.status}>{statusB}</Text>
      <Pressable
        style={styles.button}
        onPress={() => setJsModeB((prev) => !prev)}>
        <Text style={styles.buttonLabel}>
          {`B: runOnJS is ${jsModeB ? 'ON' : 'OFF'} - toggle`}
        </Text>
      </Pressable>

      <GestureDetector gesture={panC}>
        <View style={styles.pad}>
          <Text style={styles.padLabel}>Pad C - SharedValue toggle</Text>
        </View>
      </GestureDetector>
      <Text style={styles.status}>{statusC}</Text>
      <Pressable
        style={styles.button}
        onPress={() => {
          jsModeC.value = !jsModeC.value;
        }}>
        <Text style={styles.buttonLabel}>
          C: flip SharedValue (no rerender)
        </Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    padding: 20,
  },
  pad: {
    alignSelf: 'stretch',
    height: 110,
    borderRadius: 12,
    backgroundColor: '#eef',
    borderWidth: StyleSheet.hairlineWidth,
    borderColor: '#99a',
    justifyContent: 'center',
    alignItems: 'center',
  },
  padLabel: {
    color: '#667',
    fontSize: 15,
  },
  status: {
    marginTop: 6,
    marginBottom: 12,
    textAlign: 'center',
    color: '#666',
  },
  button: {
    alignSelf: 'center',
    paddingHorizontal: 16,
    paddingVertical: 8,
    borderRadius: 8,
    backgroundColor: '#dde',
    marginBottom: 16,
  },
  buttonLabel: {
    color: '#334',
  },
});

Copilot AI review requested due to automatic review settings July 22, 2026 07:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the v3 gesture hooks pipeline to avoid passing user callbacks into Reanimated’s UI runtime when runOnJS is statically true, preventing Reanimated from freezing objects captured in callback closures (and avoiding related worklets warnings).

Changes:

  • Introduces an empty “UI callbacks” object and routes it to Reanimated.useHandler / useReanimatedEventHandler when config.runOnJS === true.
  • Keeps the original callbacks for the JS event handler path, so JS-side behavior remains intact.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +61 to +64
const uiCallbacks =
config.runOnJS === true
? (EMPTY_UI_CALLBACKS as typeof callbacks)
: callbacks;

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.

@j-piasecki j-piasecki left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I assume that when runOnJS is switched on/off the hook is updated and the correct callbacks are run, right?

@m-bert

m-bert commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

I assume that when runOnJS is switched on/off the hook is updated and the correct callbacks are run, right?

Well, assuming that I tested it correctly then it does work 😅

@m-bert
m-bert merged commit bbb49c3 into main Jul 22, 2026
3 checks passed
@m-bert
m-bert deleted the @mbert/run-on-js branch July 22, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants