Skip to content

Commit 39c7051

Browse files
authored
(fix) Prevent state updates on unmounted components (#446)
This PR fixes potential memory leaks by preventing state updates on unmounted components. It does so by adding cleanup functions to useEffect hook that fetch data. Per the React docs, if your effect fetches some data, the cleanup function should either abort the fetch or ignore its result. This PR adds AbortController to cancel network requests via openmrsFetch. It also uses the ignore flag pattern for custom promises (like loadDependencies) that don't directly use openmrsFetch.
1 parent 4e34674 commit 39c7051

File tree

5 files changed

+85
-28
lines changed

5 files changed

+85
-28
lines changed

src/components/inputs/ui-select-extended/ui-select-extended.component.tsx

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import { debounce } from 'lodash-es';
33
import { ComboBox, DropdownSkeleton, Layer, InlineLoading } from '@carbon/react';
4-
import { isTrue } from '../../../utils/boolean-utils';
54
import { useTranslation } from 'react-i18next';
6-
import { getRegisteredDataSource } from '../../../registry/registry';
5+
import { useWatch } from 'react-hook-form';
6+
import { type OpenmrsResource } from '@openmrs/esm-framework';
77
import { getControlTemplate } from '../../../registry/inbuilt-components/control-templates';
8-
import { type DataSource, type FormFieldInputProps } from '../../../types';
8+
import { getRegisteredDataSource } from '../../../registry/registry';
99
import { isEmpty } from '../../../validators/form-validator';
10+
import { isTrue } from '../../../utils/boolean-utils';
11+
import { isViewMode } from '../../../utils/common-utils';
1012
import { shouldUseInlineLayout } from '../../../utils/form-helper';
11-
import FieldValueView from '../../value/view/field-value-view.component';
12-
import styles from './ui-select-extended.scss';
13+
import { type DataSource, type FormFieldInputProps } from '../../../types';
1314
import { useFormProviderContext } from '../../../provider/form-provider';
14-
import FieldLabel from '../../field-label/field-label.component';
15-
import { useWatch } from 'react-hook-form';
1615
import useDataSourceDependentValue from '../../../hooks/useDataSourceDependentValue';
17-
import { isViewMode } from '../../../utils/common-utils';
18-
import { type OpenmrsResource } from '@openmrs/esm-framework';
16+
import FieldLabel from '../../field-label/field-label.component';
17+
import FieldValueView from '../../value/view/field-value-view.component';
18+
import styles from './ui-select-extended.scss';
1919

2020
const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnings, setFieldValue }) => {
2121
const { t } = useTranslation();
@@ -49,6 +49,7 @@ const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnin
4949

5050
const debouncedSearch = debounce((searchTerm: string, dataSource: DataSource<OpenmrsResource>) => {
5151
setIsSearching(true);
52+
5253
dataSource
5354
.fetchData(searchTerm, config)
5455
.then((dataItems) => {
@@ -86,22 +87,33 @@ const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnin
8687
}, [field.questionOptions?.datasource]);
8788

8889
useEffect(() => {
90+
let ignore = false;
91+
8992
// If not searchable, preload the items
9093
if (dataSource && !isTrue(field.questionOptions.isSearchable)) {
9194
setItems([]);
9295
setIsLoading(true);
96+
9397
dataSource
9498
.fetchData(null, { ...config, referencedValue: dataSourceDependentValue })
9599
.then((dataItems) => {
96-
setItems(dataItems.map(dataSource.toUuidAndDisplay));
97-
setIsLoading(false);
100+
if (!ignore) {
101+
setItems(dataItems.map(dataSource.toUuidAndDisplay));
102+
setIsLoading(false);
103+
}
98104
})
99105
.catch((err) => {
100-
console.error(err);
101-
setIsLoading(false);
102-
setItems([]);
106+
if (!ignore) {
107+
console.error(err);
108+
setIsLoading(false);
109+
setItems([]);
110+
}
103111
});
104112
}
113+
114+
return () => {
115+
ignore = true;
116+
};
105117
}, [dataSource, config, dataSourceDependentValue]);
106118

107119
useEffect(() => {
@@ -111,19 +123,28 @@ const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnin
111123
}, [dataSource, searchTerm, config]);
112124

113125
useEffect(() => {
126+
let ignore = false;
114127
if (value && !isDirty && dataSource && isSearchable && sessionMode !== 'enter' && !items.length) {
115128
// While in edit mode, search-based instances should fetch the initial item (previously selected value) to resolve its display property
116129
setIsLoading(true);
117130
try {
118131
dataSource.fetchSingleItem(value).then((item) => {
119-
setItems([dataSource.toUuidAndDisplay(item)]);
120-
setIsLoading(false);
132+
if (!ignore) {
133+
setItems([dataSource.toUuidAndDisplay(item)]);
134+
setIsLoading(false);
135+
}
121136
});
122137
} catch (error) {
123-
console.error(error);
124-
setIsLoading(false);
138+
if (!ignore) {
139+
console.error(error);
140+
setIsLoading(false);
141+
}
125142
}
126143
}
144+
145+
return () => {
146+
ignore = true;
147+
};
127148
}, [value, isDirty, sessionMode, dataSource, isSearchable, items]);
128149

129150
if (isLoading) {

src/hooks/useEncounter.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ export function useEncounter(formJson: FormSchema) {
1111
const [error, setError] = useState(null);
1212

1313
useEffect(() => {
14+
const abortController = new AbortController();
15+
1416
if (!isEmpty(formJson.encounter) && isString(formJson.encounter)) {
15-
openmrsFetch(`${restBaseUrl}/encounter/${formJson.encounter}?v=${encounterRepresentation}`)
17+
openmrsFetch(`${restBaseUrl}/encounter/${formJson.encounter}?v=${encounterRepresentation}`, {
18+
signal: abortController.signal,
19+
})
1620
.then((response) => {
1721
setEncounter(response.data);
1822
setIsLoading(false);
@@ -26,6 +30,10 @@ export function useEncounter(formJson: FormSchema) {
2630
} else {
2731
setIsLoading(false);
2832
}
33+
34+
return () => {
35+
abortController.abort();
36+
};
2937
}, [formJson.encounter]);
3038

3139
return { encounter: encounter, error, isLoading };

src/hooks/useFormJson.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export function useFormJson(formUuid: string, rawFormJson: any, encounterUuid: s
1111
const [error, setError] = useState(validateFormsArgs(formUuid, rawFormJson));
1212

1313
useEffect(() => {
14+
const abortController = new AbortController();
15+
1416
const setFormJsonWithTranslations = (formJson: FormSchema) => {
1517
if (formJson?.translations) {
1618
const language = window.i18next.language;
@@ -24,9 +26,15 @@ export function useFormJson(formUuid: string, rawFormJson: any, encounterUuid: s
2426
setFormJsonWithTranslations({ ...formJson, encounter: encounterUuid });
2527
})
2628
.catch((error) => {
27-
console.error(error);
28-
setError(new Error('Error loading form JSON: ' + error.message));
29+
if (error.name !== 'AbortError') {
30+
console.error(error);
31+
setError(new Error('Error loading form JSON: ' + error.message));
32+
}
2933
});
34+
35+
return () => {
36+
abortController.abort();
37+
};
3038
}, [formSessionIntent, formUuid, rawFormJson, encounterUuid]);
3139

3240
return {

src/hooks/usePatientPrograms.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,29 @@ export const usePatientPrograms = (patientUuid: string, formJson: FormSchema) =>
99
const [error, setError] = useState(null);
1010

1111
useEffect(() => {
12+
const abortController = new AbortController();
13+
1214
if (formJson.meta?.programs?.hasProgramFields) {
13-
openmrsFetch(`${restBaseUrl}/programenrollment?patient=${patientUuid}&v=${customRepresentation}`)
15+
openmrsFetch(`${restBaseUrl}/programenrollment?patient=${patientUuid}&v=${customRepresentation}`, {
16+
signal: abortController.signal,
17+
})
1418
.then((response) => {
1519
setPatientPrograms(response.data.results.filter((enrollment) => enrollment.dateCompleted === null));
1620
setIsLoading(false);
1721
})
1822
.catch((error) => {
19-
setError(error);
20-
setIsLoading(false);
23+
if (error.name !== 'AbortError') {
24+
setError(error);
25+
setIsLoading(false);
26+
}
2127
});
2228
} else {
2329
setIsLoading(false);
2430
}
31+
32+
return () => {
33+
abortController.abort();
34+
};
2535
}, [formJson]);
2636

2737
return {

src/hooks/useProcessorDependencies.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,27 @@ const useProcessorDependencies = (
1313
const { loadDependencies } = formProcessor;
1414

1515
useEffect(() => {
16+
let ignore = false;
17+
1618
if (loadDependencies) {
1719
setIsLoading(true);
1820
loadDependencies(context, setContext)
19-
.then((results) => {
20-
setIsLoading(false);
21+
.then(() => {
22+
if (!ignore) {
23+
setIsLoading(false);
24+
}
2125
})
2226
.catch((error) => {
23-
setError(error);
24-
reportError(error, 'Encountered error while loading dependencies');
27+
if (!ignore) {
28+
setError(error);
29+
reportError(error, 'Encountered error while loading dependencies');
30+
}
2531
});
2632
}
33+
34+
return () => {
35+
ignore = true;
36+
};
2737
}, [loadDependencies]);
2838

2939
return { isLoading, error };

0 commit comments

Comments
 (0)