Recoil
Recoil copied to clipboard
Memory Leak on React Native iOS
We notice a memory leak with the simplest usage of Recoil when running React Native with Expo on iOS.
The reproducing app is this:
import { registerRootComponent } from "expo";
import React, { useEffect, useRef } from "react";
import { Text } from "react-native";
import { atom, RecoilRoot, useRecoilState } from "recoil";
registerRootComponent(App);
function App() {
return (
<RecoilRoot>
<MyComponent />
</RecoilRoot>
);
}
function MyComponent() {
const [state, setState] = useRecoilState(myAtom);
const updateState = () => setState(constructBigFatObject());
useEffect(() => {
const handle = setTimeout(updateState, 1);
return () => clearTimeout(handle);
}, [state]);
const renderCount = useRef(0);
return <Text>{renderCount.current++}</Text>;
}
const myAtom = atom({
key: "myAtom",
default: constructBigFatObject()
});
function constructBigFatObject() {
const obj = {};
for (let i = 0; i < 10000; i++) {
obj[i.toString()] = i;
}
return obj;
}
Basically we repeatedly create big objects and set them as atom
state.
On Android, everything works fine, memory is constant, but on iOS, memory usage of the app keeps increasing forever (until OOM).
If we replace useRecoilState
with a normal useState
, this effect vanishes, so it is not a problem of the Garbage Collector not working at all, but for some reason, it is not working for Recoil States.
This is with both 0.2.0
and 0.3.1
.
Hey folks 👋 First, thank you for this amazing library! 👏
I want to share my findings with you. I added atomFamily
and selectorFamily
to @bearbytes code because I wanted to have some dependencies. To make it a little bit more complicated.
Here is the whole repo: https://github.com/xotahal/recoil-leak (just yarn
, yarn start
& yarn ios
to run the app)
The App.tsx
is here: https://github.com/xotahal/recoil-leak/blob/master/App.tsx
And here's the simple recoil state I was using:
const myAtom = atomFamily({
key: 'myAtom',
default: null,
});
const versionState = atom({
key: 'version',
default: 0,
});
const mySelector = selectorFamily({
key: 'selector',
set:
key =>
({set}, newValue) => {
set(myAtom(key), newValue);
},
get:
key =>
({get}) => {
return get(myAtom(key));
},
});
Then my test case was increment versionState
and for each version create a new selector in mySelector
family.
const [version, setVersion] = useRecoilState(versionState);
const [state, setState] = useRecoilState(mySelector(version));
<Button
title="Create new family selector"
onPress={() => {
setVersion(current => current + 1);
}}
/>
I ran the app in a production build. Then I created 30 new selectors and took a memory snapshot. I found that every time when I created a new selector family recoil created a new state and kept the previous state. These are all version of states I had in memory snapshot.
![Screen Shot 2021-05-28 at 5 26 25 PM](https://user-images.githubusercontent.com/3531955/120008655-76fd8000-bfdb-11eb-80bb-8dd2c01a93f5.png)
![Screen Shot 2021-05-28 at 4 48 46 PM (2)](https://user-images.githubusercontent.com/3531955/120009768-9ba62780-bfdc-11eb-930c-bc8c29052593.png)
Questions
- Why do we need to keep the whole history of states?
- Is there any way how we could turn this off?
- I've read a couple of issues where you guys are talking about GC. Is this something that GC will help with in future? If so, when do you think this will be available?
- Is there anything we can do to help to resolve this?
Thanks for the reproduction repo and the thorough explanation @xotahal I came here exploring if I can use recoil in a mobile app. But this seems like a blocker. Does this happen in mobile web in iOS? in react native using Hermes in iOS? This seems related to the JavaScript engine. I still need to dig deeper to make a decision
It may be you are looking at the retention of debug states in the development build. Does this repro in the production version?
Thanks for getting back to us. I used this to build the app. Production with "Debug executable" on. I commented out the $recoilDebugStates in recoil's code. Just to be sure that it is not causing the issue.
![Screen Shot 2021-06-05 at 2 12 09 PM](https://user-images.githubusercontent.com/3531955/120891287-07554980-c608-11eb-9300-4ffb970ee2cf.png)
@drarmstr I can verify that this occurs in a production build. I've been testing exclusively in production builds. Production builds disable the debug atom state history, but selectors are currently retaining every input and every output for all time.
If you remove the selector from the example, and run in production mode, the memory leaks are substantially less. It's a selector problem.
The problem still exists (in nightly too). Is there any solution?
If selector caches are an issue, then you can try configuring them, if it is about memory leak due to atoms or selectors no longer being referenced, then that should be addressed with upcoming Recoil garbage collection.
Is there anything here unique to React Native iOS? Curious that it is reported Android has different behavior?
I'm seeing this in all flavors of ReactNative iOS and android. I did see an improvement with using the selector caches for the selector memory leak. However, the other button that creates a new family selector that causes a re-render causes the memory to increase indefinitely (even with the configured caches).
I tried the same experiment with Jotai, and I did not see any memory leak in the same way as with the recoil example described above.
Anyone knows if this still is an issue?