diff --git a/src/components/AdminPane/Manage/ProjectPickerModal/ProjectPickerModal.jsx b/src/components/AdminPane/Manage/ProjectPickerModal/ProjectPickerModal.jsx index 5d7dee8ca..5e00bc078 100644 --- a/src/components/AdminPane/Manage/ProjectPickerModal/ProjectPickerModal.jsx +++ b/src/components/AdminPane/Manage/ProjectPickerModal/ProjectPickerModal.jsx @@ -2,7 +2,7 @@ import { get as levenshtein } from "fast-levenshtein"; import _compact from "lodash/compact"; import _isEmpty from "lodash/isEmpty"; import _map from "lodash/map"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { FormattedMessage } from "react-intl"; import BusySpinner from "../../../BusySpinner/BusySpinner"; import WithPagedProjects from "../../../HOCs/WithPagedProjects/WithPagedProjects"; @@ -14,12 +14,20 @@ import messages from "./Messages"; export function ProjectPickerModal(props) { const [isSearching, setIsSearching] = useState(false); + const mountedRef = useRef(true); + useEffect( + () => () => { + mountedRef.current = false; + }, + [], + ); const executeSearch = (queryCriteria) => { if (!queryCriteria.query) { - return; // nothing to do + return; } + if (!mountedRef.current) return; setIsSearching(true); props .searchProjects( @@ -31,7 +39,7 @@ export function ProjectPickerModal(props) { queryCriteria?.page?.resultsPerPage, ) .finally(() => { - setIsSearching(false); + if (mountedRef.current) setIsSearching(false); }); }; diff --git a/src/components/ChallengeDetail/ChallengeDetail.jsx b/src/components/ChallengeDetail/ChallengeDetail.jsx index 9b65d40bf..1b1c2fdba 100644 --- a/src/components/ChallengeDetail/ChallengeDetail.jsx +++ b/src/components/ChallengeDetail/ChallengeDetail.jsx @@ -112,6 +112,7 @@ export class ChallengeDetail extends Component { resetSelectedClusters = () => this.setState({ selectedClusters: [] }); componentDidMount() { + this._isMounted = true; window.scrollTo(0, 0); const { url, params } = this.props.match; @@ -121,6 +122,10 @@ export class ChallengeDetail extends Component { } } + componentWillUnmount() { + this._isMounted = false; + } + componentDidUpdate() { if (!_isObject(this.props.user) && this.state.detailTab === DETAIL_TABS.COMMENTS) { this.setState({ detailTab: DETAIL_TABS.OVERVIEW }); @@ -150,6 +155,7 @@ export class ChallengeDetail extends Component { if (response.ok) { const body = await response.json(); + if (!this._isMounted) return; if (body?.total_count) { this.setState({ issue: body.items[0] }); } @@ -263,9 +269,8 @@ export class ChallengeDetail extends Component { disabled className="mr-bg-black-15 mr-w-full mr-p-2 mr-text-sm" style={{ height: 500 }} - > - {challenge.overpassQL} - + defaultValue={challenge.overpassQL} + /> ); case DETAIL_TABS.COMMENTS: return ( diff --git a/src/components/Dropdown/Dropdown.jsx b/src/components/Dropdown/Dropdown.jsx index 41433e58c..2d5438cca 100644 --- a/src/components/Dropdown/Dropdown.jsx +++ b/src/components/Dropdown/Dropdown.jsx @@ -27,12 +27,18 @@ const Dropdown = ({ const [visible, setVisible] = useState(false); const referenceRef = useRef(); const popperRef = useRef(); + const visibleTimeoutRef = useRef(); + + useEffect(() => { + return () => clearTimeout(visibleTimeoutRef.current); + }, []); const toggle = useCallback( (bool) => { setActive(bool); toggleVisible(); - setTimeout(() => setVisible(bool), 1); + clearTimeout(visibleTimeoutRef.current); + visibleTimeoutRef.current = setTimeout(() => setVisible(bool), 1); }, [toggleVisible], ); diff --git a/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.jsx b/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.jsx index dd4bcb371..5a5bbf976 100644 --- a/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.jsx +++ b/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.jsx @@ -157,62 +157,61 @@ export const WithChallengeTaskClusters = function ( ignoreLocked, ) .then((results) => { - if (currentFetchId >= this.state.fetchId) { - const totalCount = results.length; - // If we retrieved 1001 tasks then there might be more tasks and - // they should be clustered. So fetch as clusters - // (unless we are zoomed all the way in already) - if (totalCount > UNCLUSTER_THRESHOLD && (this.props.criteria?.zoom ?? 0) < MAX_ZOOM) { - this.props - .fetchTaskClusters(challengeId, searchCriteria, 25, overrideDisable) - .then((results) => { - const clusters = results.clusters; - if (currentFetchId >= this.state.fetchId) { - const taskCount = _sum(_map(clusters, (c) => c.numberOfPoints)); - this.setState({ - clusters, - loading: false, - taskCount: taskCount, - showAsClusters: true, - }); - } + if (!this._isMounted || currentFetchId < this.state.fetchId) return; + const totalCount = results.length; + // If we retrieved 1001 tasks then there might be more tasks and + // they should be clustered. So fetch as clusters + // (unless we are zoomed all the way in already) + if (totalCount > UNCLUSTER_THRESHOLD && (this.props.criteria?.zoom ?? 0) < MAX_ZOOM) { + this.props + .fetchTaskClusters(challengeId, searchCriteria, 25, overrideDisable) + .then((results) => { + if (!this._isMounted || currentFetchId < this.state.fetchId) return; + const clusters = results.clusters; + const taskCount = _sum(_map(clusters, (c) => c.numberOfPoints)); + this.setState({ + clusters, + loading: false, + taskCount: taskCount, + showAsClusters: true, }); - } else { - this.setState({ - clusters: results, - loading: false, - taskCount: totalCount, }); - } + } else { + this.setState({ + clusters: results, + loading: false, + taskCount: totalCount, + }); } }) .catch((error) => { console.log(error); - this.setState({ clusters: {}, loading: false, taskCount: 0 }); + if (this._isMounted) this.setState({ clusters: {}, loading: false, taskCount: 0 }); }); } else { this.props .fetchTaskClusters(challengeId, searchCriteria, 25, overrideDisable) .then((results) => { + if (!this._isMounted || currentFetchId < this.state.fetchId) return; const clusters = results.clusters; - if (currentFetchId >= this.state.fetchId) { - const taskCount = _sum(_map(clusters, (c) => c.numberOfPoints)); + const taskCount = _sum(_map(clusters, (c) => c.numberOfPoints)); + this.setState({ + clusters, + loading: false, + taskCount: taskCount, + showAsClusters: true, + }); + }) + .catch((error) => { + console.log(error); + if (this._isMounted) { this.setState({ - clusters, + clusters: {}, loading: false, - taskCount: taskCount, + taskCount: 0, showAsClusters: true, }); } - }) - .catch((error) => { - console.log(error); - this.setState({ - clusters: {}, - loading: false, - taskCount: 0, - showAsClusters: true, - }); }); } } @@ -278,6 +277,7 @@ export const WithChallengeTaskClusters = function ( }; updateTaskInClusters = async (updatedTasks) => { + if (!this._isMounted) return; if (!Array.isArray(updatedTasks)) { updatedTasks = [updatedTasks]; } diff --git a/src/components/HOCs/WithFeatured/WithFeatured.jsx b/src/components/HOCs/WithFeatured/WithFeatured.jsx index ec8b10aaf..67214f217 100644 --- a/src/components/HOCs/WithFeatured/WithFeatured.jsx +++ b/src/components/HOCs/WithFeatured/WithFeatured.jsx @@ -22,9 +22,11 @@ const WithFeatured = (WrappedComponent, options = {}) => { }; componentDidMount() { + this._isMounted = true; // Fetch featured challenges if (!options.excludeChallenges) { this.props.fetchFeaturedChallenges().then((normalizedResults) => { + if (!this._isMounted) return; if (normalizedResults && !_isEmpty(normalizedResults.entities)) { this.setState({ featuredChallenges: _filter( @@ -39,6 +41,7 @@ const WithFeatured = (WrappedComponent, options = {}) => { // Fetch featured projects if (!options.excludeProjects) { this.props.fetchFeaturedProjects().then((normalizedResults) => { + if (!this._isMounted) return; if (normalizedResults && !_isEmpty(normalizedResults.entities)) { this.setState({ featuredProjects: _values(normalizedResults.entities.projects), @@ -48,6 +51,10 @@ const WithFeatured = (WrappedComponent, options = {}) => { } } + componentWillUnmount() { + this._isMounted = false; + } + render() { return ( { + if (!this._isMounted) return; if (currentFetch >= this.state.fetchId) { this.setState({ leaderboard }); const userId = this.props.user?.id; - // The reason for using _get is that the structure of the props may vary - // depending on where this component is used, and accessing the user's score - // directly through `this.props.user.score` may result in runtime errors if - // the `user` object or the `score` property is not available in certain contexts. - // By using `_get`, we safely handle cases where the expected property may be missing - // or nested within a deeper structure. const userScore = this.props.user?.score; if (userScore && userId && !options.ignoreUser && userType !== USER_TYPE_REVIEWER) { this.props @@ -135,6 +130,7 @@ const WithLeaderboard = function (WrappedComponent, initialMonthsPast = 1, initi endDate, ) .then((userLeaderboard) => { + if (!this._isMounted) return; this.mergeInUserLeaderboard(userLeaderboard); this.setState({ leaderboardLoading: false }); }); @@ -228,6 +224,7 @@ const WithLeaderboard = function (WrappedComponent, initialMonthsPast = 1, initi }; componentDidMount() { + this._isMounted = true; if (!initialOptions.isWidget) { this.updateLeaderboard( this.monthsPast(), @@ -239,6 +236,10 @@ const WithLeaderboard = function (WrappedComponent, initialMonthsPast = 1, initi } } + componentWillUnmount() { + this._isMounted = false; + } + componentDidUpdate(prevProps) { // A change to state will also fetch leaderboard data, so we only need to // worry about fetching if we're controlled and props change. diff --git a/src/components/HOCs/WithNearbyTasks/WithNearbyTasks.jsx b/src/components/HOCs/WithNearbyTasks/WithNearbyTasks.jsx index d035f7547..f6e3b0dd0 100644 --- a/src/components/HOCs/WithNearbyTasks/WithNearbyTasks.jsx +++ b/src/components/HOCs/WithNearbyTasks/WithNearbyTasks.jsx @@ -86,6 +86,7 @@ export const WithNearbyTasks = function (WrappedComponent) { : MAX_NEARBY_TASK_LIMIT, ); + if (!this._isMounted) return; const tasksLength = nearbyTasks.tasks.length; this.setState({ nearbyTasks: { @@ -100,7 +101,7 @@ export const WithNearbyTasks = function (WrappedComponent) { }); } catch (error) { console.error("Error fetching nearby tasks:", error); - this.setState({ loading: false }); + if (this._isMounted) this.setState({ loading: false }); } } }; @@ -141,6 +142,7 @@ export const WithNearbyTasks = function (WrappedComponent) { boundingBox, MAX_NEARBY_TASK_LIMIT, ); + if (!this._isMounted) return; const tasksLength = nearbyTasks.tasks?.length; if (tasksLength > 0) { @@ -161,9 +163,14 @@ export const WithNearbyTasks = function (WrappedComponent) { }; componentDidMount() { + this._isMounted = true; this.updateNearbyTasks(); } + componentWillUnmount() { + this._isMounted = false; + } + componentDidUpdate(prevProps) { if (this.props.task && this.props.task?.id !== prevProps.task?.id) { this.setState({ diff --git a/src/components/HOCs/WithProgress/WithProgress.jsx b/src/components/HOCs/WithProgress/WithProgress.jsx index e441dc225..9aeb10465 100644 --- a/src/components/HOCs/WithProgress/WithProgress.jsx +++ b/src/components/HOCs/WithProgress/WithProgress.jsx @@ -14,8 +14,18 @@ const WithProgress = function (WrappedComponent, operationName) { stepsCompleted: 0, }; + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + updateProgress = (inProgress, stepsCompleted) => { - this.setState({ inProgress, stepsCompleted }); + if (this._isMounted) { + this.setState({ inProgress, stepsCompleted }); + } }; render() { diff --git a/src/components/HOCs/WithSystemNotices/WithSystemNotices.jsx b/src/components/HOCs/WithSystemNotices/WithSystemNotices.jsx index b0d76bb52..23b7fbc43 100644 --- a/src/components/HOCs/WithSystemNotices/WithSystemNotices.jsx +++ b/src/components/HOCs/WithSystemNotices/WithSystemNotices.jsx @@ -18,13 +18,18 @@ export const WithSystemNotices = function (WrappedComponent) { }; async componentDidMount() { + this._isMounted = true; if (!this.state.systemNotices) { const activeNotices = await fetchActiveSystemNotices(); - + if (!this._isMounted) return; this.setState({ systemNotices: activeNotices }); } } + componentWillUnmount() { + this._isMounted = false; + } + /** * Retrieves all acknowledged notices from the user's app settings * diff --git a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.jsx b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.jsx index bf874cc31..1aa5229e1 100644 --- a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.jsx +++ b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.jsx @@ -178,10 +178,10 @@ export class ActiveTaskControls extends Component { } } } catch (error) { - this.setState({ completingTask: false }); + if (this._isMounted) this.setState({ completingTask: false }); throw error; } finally { - this.setState({ completingTask: false }); + if (this._isMounted) this.setState({ completingTask: false }); } }; @@ -372,7 +372,12 @@ export class ActiveTaskControls extends Component { } } + componentDidMount() { + this._isMounted = true; + } + componentWillUnmount() { + this._isMounted = false; if (!_isEmpty(this.props.activeKeyboardShortcuts?.[hiddenShortcutGroup])) { for (const shortcut of hiddenShortcuts) { this.props.deactivateKeyboardShortcut( diff --git a/src/pages/Profile/UserSettings/UserSettings.jsx b/src/pages/Profile/UserSettings/UserSettings.jsx index 742de43ff..84c79969d 100644 --- a/src/pages/Profile/UserSettings/UserSettings.jsx +++ b/src/pages/Profile/UserSettings/UserSettings.jsx @@ -71,6 +71,7 @@ class UserSettings extends Component { editableUser.normalizeDefaultBasemap(LayerSources, editableUser.customBasemaps); this.props.updateUserSettings(this.props.user.id, editableUser).then((results) => { + if (!this._isMounted) return; // Make sure the correct defaultBasemapId is set on the form. // If a custom basemap was removed that was also set as the default, // then this would be set back to None by the normalizeDefaultBasemap(). @@ -111,9 +112,9 @@ class UserSettings extends Component { return; } this.setState({ isSaving: true, saveComplete: false }); - this.props - .updateNotificationSubscriptions(this.props.user.id, settings) - .then(() => this.setState({ isSaving: false, saveComplete: true })); + this.props.updateNotificationSubscriptions(this.props.user.id, settings).then(() => { + if (this._isMounted) this.setState({ isSaving: false, saveComplete: true }); + }); }, 750); /** Invoked when the form data is modified */ @@ -182,12 +183,17 @@ class UserSettings extends Component { }; componentDidMount() { + this._isMounted = true; // Make sure our user info is current if (this.props.user?.isLoggedIn) { this.props.loadCompleteUser(this.props.user.id); } } + componentWillUnmount() { + this._isMounted = false; + } + componentDidUpdate(prevProps) { if (this.props.user && this.props.user.id !== prevProps?.user?.id) { if (this.props.user.isLoggedIn) {