Skip to content

Commit c53e15d

Browse files
Removed memory leaks in MonkAppStateProvider and LiveConfigAppProvider components (#882)
* Added useIsMounted hook * Removed memory leaks in MonkAppStateProvider and LiveConfigAppProvider components
1 parent 1ce5f6e commit c53e15d

File tree

7 files changed

+82
-23
lines changed

7 files changed

+82
-23
lines changed

configs/test-utils/src/__mocks__/@monkvision/common.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,5 @@ export = {
136136
isInputErrorDisplayed: jest.fn(() => false),
137137
isInputTouchedOrDirty: jest.fn(() => false),
138138
})),
139+
useIsMounted: jest.fn(() => jest.fn(() => true)),
139140
};

packages/common-ui-web/src/components/LiveConfigAppProvider/LiveConfigAppProvider.tsx

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import {
22
MonkAppStateProvider,
33
MonkAppStateProviderProps,
4+
useAsyncEffect,
45
useI18nSync,
56
useLoadingState,
67
} from '@monkvision/common';
7-
import { PropsWithChildren, useCallback, useEffect, useState } from 'react';
8+
import { PropsWithChildren, useState } from 'react';
89
import { CaptureAppConfig } from '@monkvision/types';
910
import { MonkApi } from '@monkvision/network';
1011
import { useMonitoring } from '@monkvision/monitoring';
@@ -50,29 +51,29 @@ export function LiveConfigAppProvider({
5051
const loading = useLoadingState(true);
5152
const [config, setConfig] = useState<CaptureAppConfig | null>(null);
5253
const { handleError } = useMonitoring();
54+
const [retry, setRetry] = useState(0);
5355

54-
const fetchLiveConfig = useCallback(() => {
55-
if (localConfig) {
56-
loading.onSuccess();
57-
setConfig(localConfig);
58-
return;
59-
}
60-
loading.start();
61-
setConfig(null);
62-
MonkApi.getLiveConfig(id)
63-
.then((result) => {
56+
useAsyncEffect(
57+
() => {
58+
if (localConfig) {
59+
return Promise.resolve(localConfig);
60+
}
61+
loading.start();
62+
setConfig(null);
63+
return MonkApi.getLiveConfig(id);
64+
},
65+
[id, localConfig, retry],
66+
{
67+
onResolve: (result) => {
6468
loading.onSuccess();
6569
setConfig(result);
66-
})
67-
.catch((err) => {
70+
},
71+
onReject: (err) => {
6872
handleError(err);
6973
loading.onError();
70-
});
71-
}, [id, localConfig]);
72-
73-
useEffect(() => {
74-
fetchLiveConfig();
75-
}, [fetchLiveConfig]);
74+
},
75+
},
76+
);
7677

7778
if (loading.isLoading || loading.error || !config) {
7879
return (
@@ -83,7 +84,7 @@ export function LiveConfigAppProvider({
8384
<div style={styles['errorMessage']} data-testid='error-msg'>
8485
Unable to fetch application configuration. Please try again in a few minutes.
8586
</div>
86-
<Button variant='outline' icon='refresh' onClick={fetchLiveConfig}>
87+
<Button variant='outline' icon='refresh' onClick={() => setRetry((value) => value + 1)}>
8788
Retry
8889
</Button>
8990
</>

packages/common-ui-web/test/components/LiveConfigAppProvider.test.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@ jest.mock('../../src/components/Button', () => ({
66
jest.mock('../../src/components/Spinner', () => ({
77
Spinner: jest.fn(() => <></>),
88
}));
9+
const { useAsyncEffect: useAsyncEffectActual } = jest.requireActual('@monkvision/common');
910

1011
import { act, render, screen, waitFor } from '@testing-library/react';
1112
import { createFakePromise, expectPropsOnChildMock } from '@monkvision/test-utils';
12-
import { MonkAppStateProvider, useLoadingState } from '@monkvision/common';
13+
import { MonkAppStateProvider, useAsyncEffect, useLoadingState } from '@monkvision/common';
1314
import { MonkApi } from '@monkvision/network';
1415
import { Button, LiveConfigAppProvider, Spinner } from '../../src';
1516

1617
describe('LiveConfigAppProvider component', () => {
18+
beforeEach(() => {
19+
(useAsyncEffect as jest.Mock).mockImplementation(useAsyncEffectActual);
20+
});
21+
1722
afterEach(() => {
1823
jest.clearAllMocks();
1924
});

packages/common/README/HOOKS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,26 @@ function TestComponent() {
8383
This custom hook creates an interval that calls the provided callback every `delay` milliseconds. If `delay` is `null`
8484
or less than 0, the callback will not be called.
8585

86+
### useIsMounted
87+
```tsx
88+
import { useIsMounted } from '@monkvision/common';
89+
90+
function TestComponent() {
91+
const [example, setExample] = useState(0);
92+
const isMounted = useIsMounted();
93+
94+
useEffect(() => {
95+
myAsyncFunc().then((value) => {
96+
if (isMounted()) {
97+
setExample(value);
98+
}
99+
}).catch(console.error);
100+
}, [isMounted]);
101+
}
102+
```
103+
Custom hook returning a ref to a util function returning `true` if the component using the hook is mounted, and false
104+
otherwise. Can be used to cancel asynchronous calls on component unmounts.
105+
86106
### useForm
87107

88108
```tsx

packages/common/src/apps/appStateProvider.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { MonkAppState, MonkAppStateContext } from './appState';
1414
import { useAppStateMonitoring } from './monitoring';
1515
import { useAppStateAnalytics } from './analytics';
1616
import { getAvailableVehicleTypes } from '../utils';
17+
import { useIsMounted } from '../hooks/useIsMounted';
1718

1819
/**
1920
* Local storage key used within Monk web applications to store the authentication token.
@@ -87,6 +88,7 @@ export function MonkAppStateProvider({
8788
const [steeringWheel, setSteeringWheel] = useState<SteeringWheelPosition | null>(null);
8889
const availableVehicleTypes = useMemo(() => getAvailableVehicleTypes(config), [config]);
8990
const monkSearchParams = useMonkSearchParams({ availableVehicleTypes });
91+
const isMounted = useIsMounted();
9092
useAppStateMonitoring({ authToken, inspectionId, vehicleType, steeringWheel });
9193
useAppStateAnalytics({ inspectionId });
9294

@@ -100,12 +102,12 @@ export function MonkAppStateProvider({
100102
setVehicleType((param) => monkSearchParams.get(MonkSearchParam.VEHICLE_TYPE) ?? param);
101103
setSteeringWheel((param) => monkSearchParams.get(MonkSearchParam.STEERING_WHEEL) ?? param);
102104
const lang = monkSearchParams.get(MonkSearchParam.LANGUAGE);
103-
if (lang) {
105+
if (lang && isMounted()) {
104106
onFetchLanguage?.(lang);
105107
}
106108
}
107109

108-
if (fetchedToken) {
110+
if (fetchedToken && isMounted()) {
109111
setAuthToken(fetchedToken);
110112
onFetchAuthToken?.();
111113
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
3+
/**
4+
* Custom hook returning a ref to a util function returning `true` if the component using the hook is mounted, and false
5+
* otherwise.
6+
*/
7+
export function useIsMounted(): () => boolean {
8+
const isMounted = useRef(false);
9+
10+
useEffect(() => {
11+
isMounted.current = true;
12+
13+
return () => {
14+
isMounted.current = false;
15+
};
16+
}, []);
17+
18+
return useCallback(() => isMounted.current, []);
19+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { useIsMounted } from '../../src/hooks/useIsMounted';
2+
import { renderHook } from '@testing-library/react-hooks';
3+
4+
describe('useIsMounted hook', () => {
5+
it('should return true when the component is mounted and false when unmounted', () => {
6+
const { result, unmount } = renderHook(useIsMounted);
7+
expect(result.current()).toBe(true);
8+
unmount();
9+
expect(result.current()).toBe(false);
10+
});
11+
});

0 commit comments

Comments
 (0)