Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix memory leak issue with UseEffect #1506

Merged
merged 3 commits into from
Feb 18, 2020
Merged

Conversation

larrylin28
Copy link
Contributor

@larrylin28 larrylin28 commented Jan 21, 2020

There's a memory leak in react-redux's usage of useEffect, here's the detail:

In the last useIsomorphicLayoutEffect usage in connectAdvanced.js, it returns a function, unsubscribeWrapper, which will be retained by React as destroy function of the effect. Since this useEffect only subscribes to store, subscription and childPropsSelector, which in most case won't change when store state updates. So this useEffect is never called again in following updates of connected component. So the effect in the connected component will always keep a reference to the unsubscribeWrapper created in first call. But this will lead to a memory leak in modern JS VM.

In modern JS VM such as V8(Chrome), the instance of function unsubscribeWrapper will retain a closure for context when it's created. Which means, all local variables in first call of each connected component will be retained by this instance of unsubscribeWrapper, even though they are not used by it at all. In this case, the context includes actualChildProps. Which means, every connected component, will in the end retain 2 copy of its props in the memory, one as its current prop, another is the first version of its props.

It can be huge impact of memory if a connected component has props retaining a reference to big chunk of data in store state that can be fully updated to another version(e.g. data parsed from cache, network response, etc). It will end up always retaining 2 copy of that data in memory, or more if there're more other connected components.

A better JS VM should optimize to not include unused variable in the closure, but as I tested in V8 and Hermes, they both doesn't have such optimisation to avoid this case.

This can be easy reproduced:

  1. Have a connected component, reference one object in the store state.
  2. Update the store state(add a version maker in the object to help identify the issue)
  3. Use Chrome dev tools to take a heap snapshot.
  4. Search for the object in the heap snapshot, you will find 2 version of it in the heap, one retained by connected wrapped component's props, one retained by a destroy in lastEffect of connected components.

By communicating with React community, a good solution suggested is to lift useEffect outside of the hook component in such kind of case.
And this is how this PR solve the problem.

There's a memory leak in `react-redux`'s usage of UseEffect, here's the detail:
In the last useIsomorphicLayoutEffect usage in connectAdvanced.js, it returns a funcion, `unsubscribeWrapper`, which will be retained by React as `destroy` function of the effect. Since this `useEffect` only subscribes to `store`, `subscription`, `childPropsSelector`, which in most case won't change when store state updates. So this `useEffect` is never called again in following updates of `connected` component. So the effect in the `connected` component will always keep a reference to the `unsubscribeWrapper` created in first call. But this will lead to a memory leak in modern JS VM.
In modern JS VM such as V8(Chrome), the instance of function `unsubscribeWrapper` will retain a closure for context when it's created. Which means, all local variables in first call of each `connected` component will be retained by this instance of `unsubscribeWrapper`, even though they are not used by it at all. In this case, the context includes `actualChildProps`. Which means, every connected component, will in the end retain 2 copy of its props in the memory, one as it's current prop, another is the first version of its props.
It can be huge impact of memory if a connected component has props retaining a reference to big chunk of data in store state that can be fully updated to another version(e.g. data parsed from cache, network repsonse, etc). It will end up always retaining 2 copy of that data in memory, or more if there're more other `connected` compoents.
A better JS VM should optimize to not include unused variable in the closure, but as I tested in V8 and Hermes, they both doesn't have such optimisation to avoid this case.

This can be easy reproduced:
1. Have a connected component, reference one object in the store state.
2. Update the store state(add a version maker in the object to help identify the issue)
3. Use Chrome dev tools to take a heap snapshot.
4. Search for the object in the heap snapshot, you will find 2 version of it in the heap, one retained by connected wrapped component's props, one retained by a `destroy` in lastEffect of conneted compoents.

By communicating with React community, a good solution suggested is to lift `useEffect` outside of the hook component in such kind of case.
And this is how this PR solve the problem.
@larrylin28
Copy link
Contributor Author

cc @gaearon

@netlify
Copy link

netlify bot commented Jan 21, 2020

Deploy preview for react-redux-docs ready!

Built with commit 54c65df

https://deploy-preview-1506--react-redux-docs.netlify.com

Copy link
Contributor

@gaearon gaearon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. But I’ll leave merge to current maintainers.

It's historic, not related to the code as-is. That can be represented in the git history.
@timdorr
Copy link
Member

timdorr commented Jan 21, 2020

Ah, closures strike again! Always making a fool out of the reference counter...

I think this is A-OK. Let me look through some things.

@larrylin28
Copy link
Contributor Author

@timdorr Applied the suggested change.

@timdorr
Copy link
Member

timdorr commented Jan 22, 2020

LGTM. Let's get another set of eyes on it and then we can see about merging it in.

@markerikson
Copy link
Contributor

I'll try to look through it more closely this evening.

@markerikson
Copy link
Contributor

markerikson commented Jan 23, 2020

Seems plausible enough. I don't suppose there's any kind of a test we could add to cover this internal implementation change?

Also, does connect face any similar issues?

@timdorr
Copy link
Member

timdorr commented Jan 23, 2020

Not really, since it's hard to parse through heap snapshots without a whole bunch of other stuff going on.

@markerikson
Copy link
Contributor

I figured as much.

Can we at least have some kind of sandbox or something set up that shows the before and after behavior on this PR, just to confirm it really does make a difference?

@markerikson
Copy link
Contributor

Before we go any further on this PR, @Jessidhia reported a bug in Reactiflux, which I've filed as #1508 .

We should try to investigate that one first, because she offered up a suggested refactoring of useSelector to fix it. If that's correct, we would want to combine the changes from her gist and this PR - both fixing the race condition, and the memory leak issue.

@larrylin28
Copy link
Contributor Author

@markerikson how does that goes?

@markerikson
Copy link
Contributor

Awright, let's get this in.

@larrylin28 just to check, does useSelector have a similar issue?

albertodev7 pushed a commit to albertodev7/react-redux that referenced this pull request Dec 8, 2022
* Fix memory leak issue with `UseEffect`

There's a memory leak in `react-redux`'s usage of UseEffect, here's the detail:
In the last useIsomorphicLayoutEffect usage in connectAdvanced.js, it returns a funcion, `unsubscribeWrapper`, which will be retained by React as `destroy` function of the effect. Since this `useEffect` only subscribes to `store`, `subscription`, `childPropsSelector`, which in most case won't change when store state updates. So this `useEffect` is never called again in following updates of `connected` component. So the effect in the `connected` component will always keep a reference to the `unsubscribeWrapper` created in first call. But this will lead to a memory leak in modern JS VM.
In modern JS VM such as V8(Chrome), the instance of function `unsubscribeWrapper` will retain a closure for context when it's created. Which means, all local variables in first call of each `connected` component will be retained by this instance of `unsubscribeWrapper`, even though they are not used by it at all. In this case, the context includes `actualChildProps`. Which means, every connected component, will in the end retain 2 copy of its props in the memory, one as it's current prop, another is the first version of its props.
It can be huge impact of memory if a connected component has props retaining a reference to big chunk of data in store state that can be fully updated to another version(e.g. data parsed from cache, network repsonse, etc). It will end up always retaining 2 copy of that data in memory, or more if there're more other `connected` compoents.
A better JS VM should optimize to not include unused variable in the closure, but as I tested in V8 and Hermes, they both doesn't have such optimisation to avoid this case.

This can be easy reproduced:
1. Have a connected component, reference one object in the store state.
2. Update the store state(add a version maker in the object to help identify the issue)
3. Use Chrome dev tools to take a heap snapshot.
4. Search for the object in the heap snapshot, you will find 2 version of it in the heap, one retained by connected wrapped component's props, one retained by a `destroy` in lastEffect of conneted compoents.

By communicating with React community, a good solution suggested is to lift `useEffect` outside of the hook component in such kind of case.
And this is how this PR solve the problem.

* Drop this comment for now. 

It's historic, not related to the code as-is. That can be represented in the git history.

* Apply suggestions on parameters naming

Co-authored-by: Tim Dorr <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants