question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Capturing or passing a ref object to a worklet permanently breaks that ref object

See original GitHub issue

Description

In short: capturing a ref object in a worklet causes it to be frozen, meaning that it can no longer be updated. This feels like incorrect behaviour - a frozen copy should be passed to the worklet, but the original should be left unfrozen. The current behaviour causes some very tricky and hard-to-debug issues; for example if I add a console.log(myRef) inside a worklet that wasn’t capturing myRef before, I have actually changed (broken) the behaviour of my component, since myRef can no longer be updated. Adding a log shouldn’t be a breaking change!

In long: In some code I was working on, I was capturing a ref (MutableRefObject, obtained from react’s useRef) with a worklet (inside a handler passed into useAnimatedGestureHandler). I wanted the ref, rather than ref.current, because I wanted up-to-date contents of that ref at the moment the worklet is called - there might not have been a rerender since the ref value change; in other words I want to resolve .current in the worklet, not when the worklet was declared.

This gave me an unexpected result - after the worklet was first called, my ref became read-only even in code running on the React Native thread. Simply by capturing the ref in a worklet, I had frozen the ref for both threads, and could no longer update ref.current on either.

Here’s a toy component so you can see what I mean:

import { useRef, useState } from 'react';
import { Button, View } from 'react-native';
import { runOnUI } from 'react-native-reanimated';

function MyComponent() {
  const [_, setFoo] = useState({});
  const forceRerender = () => setFoo({});

  const counterRef = useRef(0);
  console.log('(RN thread) render', counterRef.current, Object.isFrozen(counterRef));

  const worklet1 = () => {
    'worklet';
    const ref = counterRef;
    console.log('(UI thread) worklet1', ref.current);
  };

  const worklet2 = () => {
    'worklet';
    console.log('(UI thread) worklet2', counterRef.current);
  };

  return <View>
    <Button onPress={() => counterRef.current += 1} title={`Increment counter: ${counterRef.current}`}/>
    <Button onPress={forceRerender} title='Force rerender'/>
    <Button onPress={() => runOnUI(worklet1)()} title='Run worklet 1'/>
    <Button onPress={() => runOnUI(worklet2)()} title='Run worklet 2'/>
  </View>;
}

Here, we can increment a counter by clicking the top button, and force a rerender with the second button, so we can see the latest value. (I’m deliberately using useRef instead of useState for the sake of this example.)

worklet1 captures the entire counterRef.

  • Good thing: if you increment the counter and then click “Run worklet 1”, you get the latest value of counterRef!
  • Bad thing: after you click “Run worklet 1”, the entire thing completely breaks. counterRef can no longer be incremented. This is the phenomenon I’m describing. worklet1 is not simply given a copy of the ref object at the moment it is called, the original ref object is frozen (Object.isFrozen(counterRef) === true).

worklet2 captures just counterRef.current, evaluated at the time that worklet2 is declared. This doesn’t suffer from the bad thing above, but also doesn’t benefit from the good thing, making it unsuitable for what I was trying to do.

Note that on web, there’s no issue, since there’s no sending of objects between thread…

Steps to reproduce

This is a general issue - see the toy component above for a repro.

Snack or a link to a repository

https://snack.expo.dev/rpPA4YXB7

Reanimated version

2.9.1

React Native version

0.69.5

Platforms

Android, iOS

JavaScript runtime

No response

Workflow

Expo managed workflow

Architecture

No response

Build type

Debug mode

Device

Real device

Device model

Samsung Galaxy S20 FE

Acknowledgements

Yes

Issue Analytics

  • State:open
  • Created a year ago
  • Reactions:2
  • Comments:6 (2 by maintainers)

github_iconTop GitHub Comments

1reaction
louiszawadzkicommented, Nov 7, 2022

I had the same issue using refs inside a Gesture.Tap().onStart callback after adding react-native-reanimated. Inside the callback I now do some stuff related to animations and some computations that use ref objects.

I had to read the thread in details to find how to solve my issue, I thought adding a small example could help the next readers, so here it is.

Here’s my component before making it work:

export const Component = () => {
  const [frequency, setFrequency] = useState(null);
  const lastTapDate = useRef(null);

  const size = useSharedValue(20);

  const animatedStyles = useAnimatedStyle(() => {
    return {
      height: size.value,
      width: size.value,
    };
  });

  const tap = Gesture.Tap().onStart(event => {
    size.value = 20;
    size.value = withSpring(300);

    // ❌ Here `lastTapDate.current` would always be undefined
    if (!lastTapDate.current) {
      lastTapDate.current = Date.now();
      return;
    }

    const pulseTime = (Date.now() - lastTapDate.current);
    setFrequency(Math.round(1 / pulseTime));
    lastTapDate.current = Date.now();
  });

  return (
    <>
      <View style={StyleSheet.absoluteFillObject}>
        {frequency !== null && <Text>{frequency}</Text>}
        <Animated.View style={[animatedStyles]} />
      </View>
      <GestureDetector gesture={tap}>
        <View style={{...StyleSheet.absoluteFillObject}} />
      </GestureDetector>
    </>
  );
};

And here’s how I fixed it, by basically putting my refs into a SharedValue object. Note that passing the return from a useRef as the SharedValue won’t work.

export const Component = () => {
  const [frequency, setFrequency] = useState(null);
  // 1️⃣ Turn the refs into a `SharedValue`
  const refs = useSharedValue({ lastTapDate: null});

  const size = useSharedValue(20);

  const animatedStyles = useAnimatedStyle(() => {
    return {
      height: size.value,
      width: size.value,
    };
  });

  // 2️⃣ Define a function outside of the tap handler
  const updateFrequency = () => {
    // ✅ Now lastTapDate gets updated
    if (!refs.value.lastTapDate) {
      refs.value.lastTapDate = Date.now();
      return;
    }

    const pulseTime = (Date.now() - refs.value.lastTapDate);
    setFrequency(Math.round(1 / pulseTime));
    refs.value.lastTapDate = Date.now();
  }

  const tap = Gesture.Tap().onStart(event => {
    size.value = 20;
    size.value = withSpring(300);

    // 3️⃣ Call the new function inside `runOnJS`
    runOnJS(updateFrequency)();
  });

  return (
    <>
      <View style={StyleSheet.absoluteFillObject}>
        {frequency !== null && <Text>{frequency}</Text>}
        <Animated.View style={[animatedStyles]} />
      </View>
      <GestureDetector gesture={tap}>
        <View style={{...StyleSheet.absoluteFillObject}} />
      </GestureDetector>
    </>
  );
};

Hope this will help others 😃

1reaction
chriscoombercommented, Nov 4, 2022

I wanted the ref, rather than ref.current, because I wanted up-to-date contents of that ref at the moment the worklet is called - there might not have been a rerender since the ref value change; in other words I want to resolve .current in the worklet, not when the worklet was declared.

This sounds like a perfect use-case for useSharedValue. Shared values are meant specifically for sharing some mutable state between JS and UI contexts, independently from React renders.

Yep, I think this was me getting confused. Objects are captured when the function declared, not when it’s called. Since the function is on a different thread, the objects are copied to the new thread, so my ref.current (from inside the function) won’t change after the point where it’s captured. I understand now that I should have used useSharedValue.

I’m still not sure about 2 things:

  1. I still feel like it shouldn’t freeze the object on the RN thread. I think the current behaviour is hard to debug, whereas if we just said “worklets are provided with deep copies of objects that are captured”, I think that would make more sense. I get that you might have bugs where you were expected an object to update, but it didn’t, but right now you have bugs where you captured an object for some innocent purpose (such as getting immutable values from the object at the time it was captured), and then it loses its mutability on the JS thread (I understand that you can work around that by manually copying it yourself). I think the latter is worse because it’s unexpected, whereas sending copies of objects to the other thread is expected.
  2. I definitely feel like this should be documented. I don’t think animation code is often particularly functional, so I don’t think it’s often harmless.

Aside: My suggestion was to not freeze the object on the JS thread, and just send an immutable copy to the UI thread. Is it possible that the problem with that is more in “how to make a deep copy”, vs “this would be hard to debug”? Usually, making a deep copy is up to the owner of the object. Rust has a concept of a Clone trait that needs to be implemented on the struct itself, for example. No library can copy your struct if it doesn’t implement this. Rust also uses traits to specify whether something should be passed by copy instead of by move (Copy, requires Clone to be implemented) (JS doesn’t have the concept of move), and whether it is safe to move to other threads (Send), or even have a reference be shared by different threads (Sync). When capturing an object in a closure (function), that closure implements Copy, Send or Sync if all of its captured objects do. Is it possible that you do a freeze on the object, because there’s no way to reliably perform a deep copy (since there’s no way for objects to have the trait Clone/Copy implemented on them)? So, I propose that you make the following a standard pattern in the docs: manually copy your object and capture just the copy in the worklet. You could even add a marker to objects that have been manually copied in this way, and the react-native-reanimated errors if you try to capture an object which doesn’t have this marker.

Aside 2: Is it possible to have an ESLint plugin that spots where an object captured in a worklet is then mutated? I find that I only sometimes get the error “Attempting to define property on object that is not extensible.” or similar, and even if I do get that error it doesn’t give me accurate line numbers. Most of the time it silently just doesn’t mutate the object.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Forwarding Refs - React
Ref forwarding is a technique for automatically passing a ref through a component to one of its children. This is typically not necessary...
Read more >
How to pass ref as a prop in object of component?
Are you just wanting to pass a React ref through GridItem to the div element, or the widget component? And where are the...
Read more >
USING THE - Library of Congress
Record as many terms as applicable to the resource being described. If the resource being described consists of more than one carrier type,...
Read more >
Web Audio API - W3C
Allows access to the Worklet object that can import a script ... Get a reference to the bytes held by the Uint8Array passed...
Read more >
In Depth Vue 3 For Beginners | Chris Dixon - Skillshare
Passing Dynamic Data & Scope ... 95. Adding Reactivity With Ref. 3:15 ... In the upcoming videos, we'll also pass a Options object...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found