Skip to content

Commit b58f4c7

Browse files
committed
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.
1 parent 77a2044 commit b58f4c7

File tree

1 file changed

+148
-96
lines changed

1 file changed

+148
-96
lines changed

src/components/connectAdvanced.js

Lines changed: 148 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,127 @@ function storeStateUpdatesReducer(state, action) {
2323
return [action.payload, updateCount + 1]
2424
}
2525

26+
function useIsomorphicLayoutEffectWithArgs(create, deps, args) {
27+
useIsomorphicLayoutEffect(() => create(...args), deps)
28+
}
29+
30+
function captureWrapperProps(
31+
lastWrapperProps,
32+
lastChildProps,
33+
renderIsScheduled,
34+
wrapperProps,
35+
actualChildProps,
36+
childPropsFromStoreUpdate,
37+
notifyNestedSubs
38+
) {
39+
// We want to capture the wrapper props and child props we used for later comparisons
40+
lastWrapperProps.current = wrapperProps
41+
lastChildProps.current = actualChildProps
42+
renderIsScheduled.current = false
43+
44+
// If the render was from a store update, clear out that reference and cascade the subscriber update
45+
if (childPropsFromStoreUpdate.current) {
46+
childPropsFromStoreUpdate.current = null
47+
notifyNestedSubs()
48+
}
49+
}
50+
51+
function subscribeUpdates(
52+
shouldHandleStateChanges,
53+
store,
54+
subscription,
55+
childPropsSelector,
56+
lastWrapperProps,
57+
lastChildProps,
58+
renderIsScheduled,
59+
childPropsFromStoreUpdate,
60+
notifyNestedSubs,
61+
forceComponentUpdateDispatch
62+
) {
63+
// If we're not subscribed to the store, nothing to do here
64+
if (!shouldHandleStateChanges) return
65+
66+
// Capture values for checking if and when this component unmounts
67+
let didUnsubscribe = false
68+
let lastThrownError = null
69+
70+
// We'll run this callback every time a store subscription update propagates to this component
71+
const checkForUpdates = () => {
72+
if (didUnsubscribe) {
73+
// Don't run stale listeners.
74+
// Redux doesn't guarantee unsubscriptions happen until next dispatch.
75+
return
76+
}
77+
78+
const latestStoreState = store.getState()
79+
80+
let newChildProps, error
81+
try {
82+
// Actually run the selector with the most recent store state and wrapper props
83+
// to determine what the child props should be
84+
newChildProps = childPropsSelector(
85+
latestStoreState,
86+
lastWrapperProps.current
87+
)
88+
} catch (e) {
89+
error = e
90+
lastThrownError = e
91+
}
92+
93+
if (!error) {
94+
lastThrownError = null
95+
}
96+
97+
// If the child props haven't changed, nothing to do here - cascade the subscription update
98+
if (newChildProps === lastChildProps.current) {
99+
if (!renderIsScheduled.current) {
100+
notifyNestedSubs()
101+
}
102+
} else {
103+
// Save references to the new child props. Note that we track the "child props from store update"
104+
// as a ref instead of a useState/useReducer because we need a way to determine if that value has
105+
// been processed. If this went into useState/useReducer, we couldn't clear out the value without
106+
// forcing another re-render, which we don't want.
107+
lastChildProps.current = newChildProps
108+
childPropsFromStoreUpdate.current = newChildProps
109+
renderIsScheduled.current = true
110+
111+
// If the child props _did_ change (or we caught an error), this wrapper component needs to re-render
112+
forceComponentUpdateDispatch({
113+
type: 'STORE_UPDATED',
114+
payload: {
115+
error
116+
}
117+
})
118+
}
119+
}
120+
121+
// Actually subscribe to the nearest connected ancestor (or store)
122+
subscription.onStateChange = checkForUpdates
123+
subscription.trySubscribe()
124+
125+
// Pull data from the store after first render in case the store has
126+
// changed since we began.
127+
checkForUpdates()
128+
129+
const unsubscribeWrapper = () => {
130+
didUnsubscribe = true
131+
subscription.tryUnsubscribe()
132+
subscription.onStateChange = null
133+
134+
if (lastThrownError) {
135+
// It's possible that we caught an error due to a bad mapState function, but the
136+
// parent re-rendered without this component and we're about to unmount.
137+
// This shouldn't happen as long as we do top-down subscriptions correctly, but
138+
// if we ever do those wrong, this throw will surface the error in our tests.
139+
// In that case, throw the error from here so it doesn't get lost.
140+
throw lastThrownError
141+
}
142+
}
143+
144+
return unsubscribeWrapper
145+
}
146+
26147
const initStateUpdates = () => [null, 0]
27148

28149
export default function connectAdvanced(
@@ -278,107 +399,38 @@ export default function connectAdvanced(
278399
return childPropsSelector(store.getState(), wrapperProps)
279400
}, [store, previousStateUpdateResult, wrapperProps])
280401

402+
// To avoid memory leak issue of function closure in useEffect, move useEffect functions out of component scope.
403+
281404
// We need this to execute synchronously every time we re-render. However, React warns
282405
// about useLayoutEffect in SSR, so we try to detect environment and fall back to
283406
// just useEffect instead to avoid the warning, since neither will run anyway.
284-
useIsomorphicLayoutEffect(() => {
285-
// We want to capture the wrapper props and child props we used for later comparisons
286-
lastWrapperProps.current = wrapperProps
287-
lastChildProps.current = actualChildProps
288-
renderIsScheduled.current = false
289-
290-
// If the render was from a store update, clear out that reference and cascade the subscriber update
291-
if (childPropsFromStoreUpdate.current) {
292-
childPropsFromStoreUpdate.current = null
293-
notifyNestedSubs()
294-
}
295-
})
407+
useIsomorphicLayoutEffectWithArgs(captureWrapperProps, undefined, [
408+
lastWrapperProps,
409+
lastChildProps,
410+
renderIsScheduled,
411+
wrapperProps,
412+
actualChildProps,
413+
childPropsFromStoreUpdate,
414+
notifyNestedSubs
415+
])
296416

297417
// Our re-subscribe logic only runs when the store/subscription setup changes
298-
useIsomorphicLayoutEffect(() => {
299-
// If we're not subscribed to the store, nothing to do here
300-
if (!shouldHandleStateChanges) return
301-
302-
// Capture values for checking if and when this component unmounts
303-
let didUnsubscribe = false
304-
let lastThrownError = null
305-
306-
// We'll run this callback every time a store subscription update propagates to this component
307-
const checkForUpdates = () => {
308-
if (didUnsubscribe) {
309-
// Don't run stale listeners.
310-
// Redux doesn't guarantee unsubscriptions happen until next dispatch.
311-
return
312-
}
313-
314-
const latestStoreState = store.getState()
315-
316-
let newChildProps, error
317-
try {
318-
// Actually run the selector with the most recent store state and wrapper props
319-
// to determine what the child props should be
320-
newChildProps = childPropsSelector(
321-
latestStoreState,
322-
lastWrapperProps.current
323-
)
324-
} catch (e) {
325-
error = e
326-
lastThrownError = e
327-
}
328-
329-
if (!error) {
330-
lastThrownError = null
331-
}
332-
333-
// If the child props haven't changed, nothing to do here - cascade the subscription update
334-
if (newChildProps === lastChildProps.current) {
335-
if (!renderIsScheduled.current) {
336-
notifyNestedSubs()
337-
}
338-
} else {
339-
// Save references to the new child props. Note that we track the "child props from store update"
340-
// as a ref instead of a useState/useReducer because we need a way to determine if that value has
341-
// been processed. If this went into useState/useReducer, we couldn't clear out the value without
342-
// forcing another re-render, which we don't want.
343-
lastChildProps.current = newChildProps
344-
childPropsFromStoreUpdate.current = newChildProps
345-
renderIsScheduled.current = true
346-
347-
// If the child props _did_ change (or we caught an error), this wrapper component needs to re-render
348-
forceComponentUpdateDispatch({
349-
type: 'STORE_UPDATED',
350-
payload: {
351-
error
352-
}
353-
})
354-
}
355-
}
356-
357-
// Actually subscribe to the nearest connected ancestor (or store)
358-
subscription.onStateChange = checkForUpdates
359-
subscription.trySubscribe()
360-
361-
// Pull data from the store after first render in case the store has
362-
// changed since we began.
363-
checkForUpdates()
364-
365-
const unsubscribeWrapper = () => {
366-
didUnsubscribe = true
367-
subscription.tryUnsubscribe()
368-
subscription.onStateChange = null
369-
370-
if (lastThrownError) {
371-
// It's possible that we caught an error due to a bad mapState function, but the
372-
// parent re-rendered without this component and we're about to unmount.
373-
// This shouldn't happen as long as we do top-down subscriptions correctly, but
374-
// if we ever do those wrong, this throw will surface the error in our tests.
375-
// In that case, throw the error from here so it doesn't get lost.
376-
throw lastThrownError
377-
}
378-
}
379-
380-
return unsubscribeWrapper
381-
}, [store, subscription, childPropsSelector])
418+
useIsomorphicLayoutEffectWithArgs(
419+
subscribeUpdates,
420+
[store, subscription, childPropsSelector],
421+
[
422+
shouldHandleStateChanges,
423+
store,
424+
subscription,
425+
childPropsSelector,
426+
lastWrapperProps,
427+
lastChildProps,
428+
renderIsScheduled,
429+
childPropsFromStoreUpdate,
430+
notifyNestedSubs,
431+
forceComponentUpdateDispatch
432+
]
433+
)
382434

383435
// Now that all that's done, we can finally try to actually render the child component.
384436
// We memoize the elements for the rendered child component as an optimization.

0 commit comments

Comments
 (0)