facebookexperimental / Recoil

Recoil is an experimental state management library for React apps. It provides several capabilities that are difficult to achieve with React alone, while being compatible with the newest features of React.
https://recoiljs.org/
MIT License
19.61k stars 1.18k forks source link

Memory Leak on React Native iOS #1040

Open pothos-dev opened 3 years ago

pothos-dev commented 3 years ago

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.

xotahal commented 3 years ago

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?
haikyuu commented 3 years ago

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

drarmstr commented 3 years ago

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

xotahal commented 3 years ago

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
andrewagain commented 3 years ago

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

andrewJA commented 3 years ago

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

drarmstr commented 3 years ago

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?

rdy commented 3 years ago

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.

ngort01 commented 2 years ago

Anyone knows if this still is an issue?