Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
## Summary Currently, every time we want to execute a shareable worklet, we need to call `toJSValue()` to convert Reanimated's Shareable into a runnable JavaScript function. This operation can be quite expensive for larger methods that have many dependencies in their closure (such as objects and other worklets). Previously, the result of `toJSValue()` wasn't cached, which meant we had to convert the same shareable multiple times, especially on every call to `runOnUI()` and in response to events - potentially on every frame. This happens because the part of the code - `runGuarded` - is called frequently. You can see this code [here](https://github.com/software-mansion/react-native-reanimated/blob/3.17.0-rc.0/packages/react-native-reanimated/Common/cpp/worklets/WorkletRuntime/WorkletRuntime.h#L36). This PR introduces the retention of all worklets and caches the result of `toJSValue()` per runtime.⚠️ This change is potentially risky, and it's challenging to predict if there are any edge cases where caching everything might not be appropriate. However, at this moment, we haven't found any regressions related to memory issues or crashes.⚠️ This PR changes the default behavior of worklets. Previously, worklets were stateless and destroyed their closure after every invocation, but now the closure will persist as long as the worklet lives. #### stateless vs stateful example ```js export default function Example() { let counter = {value: 0}; const workletFunction = () => { 'worklet'; counter.value++; console.log(counter); }; return <Button title="click" onPress={runOnUI(workletFunction)}/>; } ``` **Previous output** ``` {"value": 1} {"value": 1} {"value": 1} ``` **Current output** ``` {"value": 1} {"value": 2} {"value": 3} ``` However, after the render, the worklet and their closure will be created again. #### Issue reproduction example <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import React from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI } from 'react-native-reanimated'; function mleko() { 'worklet'; console.log('mleko'); } export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); return ( <View style={styles.container}> <Button onPress={() => { runOnUI(() => { mleko(); withTiming; console.log('runOnUI'); scrollTo(aref, 0, 100, true); })(); }} title='click' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details> #### Memory test example I have tested whether this change leads to memory leaks, and according to my tests, the behavior remains exactly the same as before. Here is my test example: <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import {useRef} from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI } from 'react-native-reanimated'; export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); const ref = useRef(0); function test1() { const obj = {a: 5}; for(let i = 0; i < 10000; i++) { runOnUI(() => { const a = 5 + obj.a; if (a < 5) { console.log('a', a); } })(); } } return ( <View style={styles.container}> <Button onPress={test1} title='test1' /> <Button onPress={() => { global.gc(); runOnUI(() => { global.gc(); })(); }} title='gc' /> <Button onPress={() => { runOnUI(() => { withTiming; scrollTo(aref, 0, 100, true); })(); }} title='scroll' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details> To test for memory leaks, you can follow these steps: 1. Add a counter of shareable worklets ```diff +int ShareableWorklet::objCounter = 0; ShareableWorklet::ShareableWorklet(jsi::Runtime &rt, const jsi::Object &worklet) : ShareableObject(rt, worklet) { valueType_ = WorkletType; + objCounter++; } ShareableWorklet::~ShareableWorklet() { + objCounter--; } ``` 2. Add breakpoints to the constructor and destructor. 3. Press the `test1` button a few times. 4. Press the `gc` (garbage collection) button a few times. 5. Check if the counter returns to the value it had at the beginning. 6. Note: The counter should never reach 0 because there are some internal worklets that exist in the global scope and should never be destructed during the lifetime of the React Context. #### Additional tests cases I've also checked for any regressions in our Example app, but everything seems to be functioning normally. <details> <summary>code</summary> ```js import { Text, StyleSheet, View, Button } from 'react-native'; import React, {useEffect, useRef} from 'react'; import Animated, { withSpring, useAnimatedScrollHandler, withClamp, withDecay, withDelay, withTiming, useAnimatedRef, scrollTo, runOnUI, runOnJS, useSharedValue } from 'react-native-reanimated'; function mleko() { 'worklet'; console.log('mleko'); } export default function EmptyExample() { const aref = useAnimatedRef<Animated.ScrollView>(); const onScroll = useAnimatedScrollHandler({ onBeginDrag: () => { 'worklet' withSpring; withClamp; withDecay; withDelay; withTiming; }, }); const ref = useRef(0); const sv = useSharedValue(0); function test1() { sv.value++; function tmp(arg: number) { sv.value++; console.log('tmp', ref.current, sv.value); } const obj = {a: 5}; for(let i = 0; i < 10000; i++) { runOnUI(() => { const a = 5 + obj.a; if (a < 5) { console.log('a', a); } })(); } } const remoteObject = {a: 5}; function test2() { sv.value++; remoteObject.a++; function a({a, b}: {a: number, b: number} = {a: 5, b: 10}) { sv.value++; console.log('a', a, b, ref, remoteObject, sv.value); } function schedule(obj: any) { sv.value++; console.log('schedule', obj, remoteObject, sv.value); runOnUI((tmp) => { sv.value++; console.log('runOnUI', tmp, sv.value); runOnJS(a)(tmp as any); })({a: 3, b: 4}); } runOnUI(() => { sv.value++; runOnJS(schedule)({a: 1, b: 2}); })(); } useEffect(() => { // setInterval(() => { // ref.current++; // console.log('ref', ref.current); // }, 1000); }, []); return ( <View style={styles.container}> <Button onPress={test1} title='test1' /> <Button onPress={test2} title='test2' /> <Button onPress={() => { global.gc(); runOnUI(() => { global.gc(); })(); }} title='gc' /> <Button onPress={() => { runOnUI(() => { mleko(); withTiming; console.log('runOnUI'); scrollTo(aref, 0, 100, true); })(); }} title='scroll' /> <Animated.ScrollView onScroll={onScroll} ref={aref}> {Array.from({ length: 1000 }, (_, i) => ( <Text key={i}>Item_____________ {i}</Text> ))} </Animated.ScrollView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', marginTop: 100, }, }); ``` </details>
- Loading branch information