Recoil icon indicating copy to clipboard operation
Recoil copied to clipboard

Memory Leak on React Native iOS

Open pothos-dev opened this issue 3 years ago • 9 comments

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.

pothos-dev avatar May 21 '21 16:05 pothos-dev

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 Screen Shot 2021-05-28 at 4 48 46 PM (2)

Questions

  1. Why do we need to keep the whole history of states?
  2. Is there any way how we could turn this off?
  3. 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?
  4. Is there anything we can do to help to resolve this?

xotahal avatar May 28 '21 15:05 xotahal

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

haikyuu avatar Jun 03 '21 12:06 haikyuu

It may be you are looking at the retention of debug states in the development build. Does this repro in the production version?

drarmstr avatar Jun 04 '21 18:06 drarmstr

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

xotahal avatar Jun 05 '21 12:06 xotahal

@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.

andrewagain avatar Jun 09 '21 18:06 andrewagain

The problem still exists (in nightly too). Is there any solution?

andrewJA avatar Jun 21 '21 08:06 andrewJA

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?

drarmstr avatar Aug 13 '21 19:08 drarmstr

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.

rdy avatar Aug 16 '21 17:08 rdy

Anyone knows if this still is an issue?

ngort01 avatar Mar 17 '22 11:03 ngort01