Repository: LiteFarmOrg/LiteFarm
Pull request #3742: LF-4797 Clean up Google maps event listeners added with `maps.event.addListener()`
Author: login: kathyavini
Labels: name: bug
description: Something isn't working
Created: 2025-04-17T19:55:08Z | Merged: 2025-04-24T13:22:47Z

**Description**

Based on [this conversation](https://github.com/LiteFarmOrg/LiteFarm/pull/3731#discussion_r2047585402) in Github.

Event listeners have always been added to our Google Map objects in each of our map components without cleanup when the component unmounts. In general this seems like a bad idea, although I dislike that the Google Maps API documentation is not really clear about the recommended cleanup pattern (e.g. there is no mention of cleanup at all in the `event.addListener()` [example code or description](https://developers.google.com/maps/documentation/javascript/events#EventListeners)) which made me wonder if Google Maps was even handling it under the hood 🤷

The cleanup was evaluated with the debugging functions (below) which verified that `hasListeners()` became false, but as mentioned in the GitHub thread linked above, _**I'm not sure how to evaluate how this actually impacts our application in terms of memory leaks or performance.**_

### The debugging functions and their output
Included in this PR is are the "debugging" functions I used to evaluate which listeners were present when the map components unmount. Because of tree-shaking, the debugging functions won't be included in the prod build and I think are fine to keep in the repo. But if they don't seem useful, I can also remove them from the PR.

If you use the debugging functions place of the cleanup functions, you see that even when our code doesn't add `mouseenter` and `mouseout` events (i.e. when `disableHover = true`) it seems the map objects get them -- I guess these are added by Google Maps itself and must be controlling that cursor change when hovering a map object?

<img width="328" alt="Screenshot 2025-04-17 at 1 42 00 PM" src="https://github.com/user-attachments/assets/1d31ad03-1962-4657-91d4-5a7ae795a568" />


### Event listeners on the map itself are cleaned up by `google-map-react`

Okay, this one took me a hot minute to understand :rofl:

Using the debugging function on the map itself (`gMap`/  `mapRef`) always shows no events available for removal when the component unmounts:

<img width="269" alt="Screenshot 2025-04-17 at 2 50 08 PM" src="https://github.com/user-attachments/assets/0f048263-b99c-451d-bcd0-22c9b43760e5" />

UNLESS you edit the code and cause a hot module reload on that page -- then the mouse events and idle are all true:

<img width="258" alt="Screenshot 2025-04-17 at 2 42 24 PM" src="https://github.com/user-attachments/assets/05d85d01-7c2b-4042-bad7-30935450af27" />


I _think_ the difference is that the hot module reload doesn't trigger a normal teardown (unmount).

It turns out that `clearInstanceListeners(this.map_)` is called by `google-maps-react`'s [cleanup code](https://github.com/google-map-react/google-map-react/blob/153fa44ab8f164acc8db706e297b09f588c4781f/src/google_map.js#L457), and so has already run by the time our parent component's `useEffect()` is triggered.

Therefore I think it's fine to continue to not run the cleanup functions on the `mapRef` / `gMap`. I have only included cleanup on instances that returned at least one `hasListeners()` true when our Map wrapping component unmounts.

Jira link:  https://lite-farm.atlassian.net/browse/LF-4797

**Type of change**

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

**How Has This Been Tested?**

- [ ] Passes test case
- [ ] UI components visually reviewed on desktop view
- [ ] UI components visually reviewed on mobile view
- [x] Other (please explain)

Used the debugging functions included in the PR as described above.

**Checklist:**

- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] The precommit and linting ran successfully
- [ ] I have added or updated language tags for text that's part of the UI
- [ ] I have added "MISSING" for all new language tags to languages I don't speak
- [ ] I have added [the GNU General Public License](https://lite-farm.atlassian.net/l/cp/BT0Dd7WW) to all new files


Diff:
diff --git a/packages/webapp/package.json b/packages/webapp/package.json
index a26f3d39f6..c146f19afa 100644
--- a/packages/webapp/package.json
+++ b/packages/webapp/package.json
@@ -109,6 +109,7 @@
     "@storybook/test": "^8.2.8",
     "@storybook/test-runner": "^0.19.1",
     "@types/d3": "^7.4.0",
+    "@types/google.maps": "^3.58.1",
     "@types/lodash-es": "^4.17.12",
     "@types/prop-types": "^15.7.5",
     "@types/react": "^18.0.25",
diff --git a/packages/webapp/pnpm-lock.yaml b/packages/webapp/pnpm-lock.yaml
index 1844dc496d..a298ebad05 100644
--- a/packages/webapp/pnpm-lock.yaml
+++ b/packages/webapp/pnpm-lock.yaml
@@ -239,6 +239,9 @@ importers:
       '@types/d3':
         specifier: ^7.4.0
         version: 7.4.3
+      '@types/google.maps':
+        specifier: ^3.58.1
+        version: 3.58.1
       '@types/lodash-es':
         specifier: ^4.17.12
         version: 4.17.12
@@ -2399,6 +2402,9 @@ packages:
   '@types/geojson@7946.0.16':
     resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
 
+  '@types/google.maps@3.58.1':
+    resolution: {integrity: sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==}
+
   '@types/graceful-fs@4.1.9':
     resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
 
@@ -9672,6 +9678,8 @@ snapshots:
 
   '@types/geojson@7946.0.16': {}
 
+  '@types/google.maps@3.58.1': {}
+
   '@types/graceful-fs@4.1.9':
     dependencies:
       '@types/node': 22.13.11
diff --git a/packages/webapp/src/components/LocationPicker/SingleLocationPicker/index.jsx b/packages/webapp/src/components/LocationPicker/SingleLocationPicker/index.jsx
index 95fb701b8c..033844e41d 100644
--- a/packages/webapp/src/components/LocationPicker/SingleLocationPicker/index.jsx
+++ b/packages/webapp/src/components/LocationPicker/SingleLocationPicker/index.jsx
@@ -25,6 +25,10 @@ import styles from './styles.module.scss';
 import { icons, selectedIcons } from '../../../containers/Map/mapStyles';
 import clsx from 'clsx';
 import { GestureHandling } from './types';
+import {
+  cleanupGeometryListeners,
+  cleanupInstanceListeners,
+} from '../../../util/google-maps/cleanupListeners';
 
 const LocationPicker = ({
   onSelectLocation,
@@ -84,6 +88,16 @@ const LocationPicker = ({
     if (maxZoom && gMap && gMaps && gMapBounds) {
       drawAllLocations(gMap, gMaps, gMapBounds);
     }
+
+    // Cleanup event listeners
+    return () => {
+      if (gMaps && geometriesRef.current) {
+        cleanupGeometryListeners(geometriesRef.current, gMaps);
+      }
+      if (gMaps && markerClusterRef.current) {
+        cleanupInstanceListeners(markerClusterRef.current, gMaps);
+      }
+    };
   }, [maxZoom, gMap, gMaps, gMapBounds]);
 
   useEffect(() => {
diff --git a/packages/webapp/src/containers/Map/index.jsx b/packages/webapp/src/containers/Map/index.jsx
index a1107f45be..38af8c0bbd 100644
--- a/packages/webapp/src/containers/Map/index.jsx
+++ b/packages/webapp/src/containers/Map/index.jsx
@@ -62,6 +62,10 @@ import {
 } from './mapAddDrawerSlice';
 import clsx from 'clsx';
 import { ADD_SENSORS_URL } from '../../util/siteMapConstants';
+import {
+  cleanupGeometryListeners,
+  cleanupInstanceListeners,
+} from '../../util/google-maps/cleanupListeners';
 
 export default function Map({ history, isCompactSideMenu }) {
   const { farm_name, grid_points, is_admin, farm_id } = useSelector(userFarmSelector);
@@ -185,11 +189,28 @@ export default function Map({ history, isCompactSideMenu }) {
       fullscreenControl: false,
     };
   };
-  const { drawAssets } = useMapAssetRenderer({
+  const { drawAssets, assetGeometriesRef, markerClusterRef } = useMapAssetRenderer({
     isClickable: !drawingState.type,
     drawingState: drawingState,
     showingConfirmButtons: showingConfirmButtons,
   });
+
+  // Cleanup listeners on map instance objects
+  useEffect(() => {
+    if (!gMaps) return;
+    return () => {
+      if (assetGeometriesRef.current) {
+        cleanupGeometryListeners(assetGeometriesRef.current, gMaps);
+      }
+      if (markerClusterRef.current) {
+        cleanupInstanceListeners(markerClusterRef.current, gMaps);
+      }
+      if (drawingState.drawingManager) {
+        cleanupInstanceListeners(drawingState.drawingManager, gMaps);
+      }
+    };
+  }, [gMaps]);
+
   const { getMaxZoom, maxZoom } = useMaxZoom();
   const handleGoogleMapApi = (map, maps) => {
     getMaxZoom(maps, map);
diff --git a/packages/webapp/src/containers/Map/useMapAssetRenderer.js b/packages/webapp/src/containers/Map/useMapAssetRenderer.js
index 0dbf11ded3..b843c6dcc5 100644
--- a/packages/webapp/src/containers/Map/useMapAssetRenderer.js
+++ b/packages/webapp/src/containers/Map/useMapAssetRenderer.js
@@ -61,6 +61,13 @@ const useMapAssetRenderer = ({ isClickable, showingConfirmButtons, drawingState
   const [points, setPoints] = useState({});
 
   const [assetGeometries, setAssetGeometries] = useState(initAssetGeometriesState());
+  const assetGeometriesRef = useRef({});
+
+  // Keep ref (for cleanup) in sync with state
+  useEffect(() => {
+    assetGeometriesRef.current = assetGeometries;
+  }, [assetGeometries]);
+
   //TODO get prev filter state from redux
   const [prevFilterState, setPrevFilterState] = useState(filterSettings);
   useEffect(() => {
@@ -553,7 +560,14 @@ const useMapAssetRenderer = ({ isClickable, showingConfirmButtons, drawingState
     };
   };
 
-  return { drawAssets, drawArea, drawPoint, drawLine };
+  return {
+    drawAssets,
+    drawArea,
+    drawPoint,
+    drawLine,
+    assetGeometriesRef,
+    markerClusterRef,
+  };
 };
 
 export default useMapAssetRenderer;
diff --git a/packages/webapp/src/util/google-maps/cleanupListeners.ts b/packages/webapp/src/util/google-maps/cleanupListeners.ts
new file mode 100644
index 0000000000..844a719491
--- /dev/null
+++ b/packages/webapp/src/util/google-maps/cleanupListeners.ts
@@ -0,0 +1,67 @@
+/*
+ *  Copyright 2025 LiteFarm.org
+ *  This file is part of LiteFarm.
+ *
+ *  LiteFarm is free software: you can redistribute it and/or modify
+ *  it under the terms of the GNU General Public License as published by
+ *  the Free Software Foundation, either version 3 of the License, or
+ *  (at your option) any later version.
+ *
+ *  LiteFarm is distributed in the hope that it will be useful,
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ *  GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
+ */
+
+export const instanceTypes = ['polygon', 'marker', 'polyline', 'circle'] as const;
+
+export interface Geometry {
+  polygon?: google.maps.Polygon;
+  marker?: google.maps.Marker;
+  polyline?: google.maps.Polyline;
+  circle?: google.maps.Circle;
+
+  // Location data shape differs between Map and LocationPicker
+  location?: { name?: string; location_id: string };
+  location_name?: string;
+  location_id?: string;
+}
+
+/**
+ * Clears all event listeners from a given Google Maps instance object.
+ *
+ * @param {google.maps.MVCObject} instance - The instance from which to clear listeners.
+ * @param {typeof google.maps} gMaps - The Google Maps API reference.
+ */
+export function cleanupInstanceListeners(
+  instance: google.maps.MVCObject,
+  gMaps: typeof google.maps,
+) {
+  gMaps.event.clearInstanceListeners(instance);
+}
+
+/**
+ * Iterates over a set of Geometry entries—each of which may be a single Geometry
+ * or an array of Geometry objects—and clears all event listeners on each
+ * contained Google Maps overlay (polygon, marker, polyline, circle).
+ *
+ * @param {Record<string, Geometry | Geometry[]>} geometries
+ *   An object mapping keys (either location IDs or location types) to a single Geometry or an array of Geometry items.
+ * @param {typeof google.maps} gMaps
+ *   The Google Maps API reference.
+ */
+export function cleanupGeometryListeners(
+  geometries: Record<string, Geometry | Geometry[]>,
+  gMaps: typeof google.maps,
+) {
+  Object.values(geometries).forEach((entry) => {
+    const items = Array.isArray(entry) ? entry : [entry];
+    items.forEach((geometry) => {
+      instanceTypes.forEach((instanceKey) => {
+        if (geometry[instanceKey]) {
+          cleanupInstanceListeners(geometry[instanceKey], gMaps);
+        }
+      });
+    });
+  });
+}
diff --git a/packages/webapp/src/util/google-maps/debugListeners.ts b/packages/webapp/src/util/google-maps/debugListeners.ts
new file mode 100644
index 0000000000..48d2e8a180
--- /dev/null
+++ b/packages/webapp/src/util/google-maps/debugListeners.ts
@@ -0,0 +1,104 @@
+/*
+ *  Copyright 2025 LiteFarm.org
+ *  This file is part of LiteFarm.
+ *
+ *  LiteFarm is free software: you can redistribute it and/or modify
+ *  it under the terms of the GNU General Public License as published by
+ *  the Free Software Foundation, either version 3 of the License, or
+ *  (at your option) any later version.
+ *
+ *  LiteFarm is distributed in the hope that it will be useful,
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ *  GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
+ */
+
+import { Geometry, instanceTypes } from './cleanupListeners';
+
+const userEvents = ['click', 'mouseover', 'mouseout'];
+const mapEvents = [
+  'set_at',
+  'insert_at',
+  'polygoncomplete',
+  'polylinecomplete',
+  'overlaycomplete',
+  'visible_changed',
+  'idle',
+];
+
+const eventTypes = [...userEvents, ...mapEvents];
+
+/*---------------------------------
+ Use the helper tools below to evaluate the listeners on your Google Map components
+
+ These were written for <LocationPicker /> and subsequently extended for <Map /> to decide which objects needed to be cleared
+
+ E.g. in <LocationPicker />
+
+      if (gMaps && geometriesRef.current) {
+        debugGeometryListeners(geometriesRef.current, gMaps);
+      }
+      if (gMaps && markerClusterRef.current) {
+        debugInstanceListeners(markerClusterRef.current, gMaps, 'markerCluster');
+      }
+      if (gMaps && pinMarkerRef.current) {
+        debugInstanceListeners(pinMarkerRef.current, gMaps, 'pinMarker');
+      }
+
+ ---------------------------------------------*/
+
+/**
+ * Logs the event listeners on each Geometry object contained within the provided collection.
+ *
+ * @param {{ [key: string]: Geometry }} geometries - An object mapping keys to Geometry objects.
+ * @param {typeof google.maps} gMaps - The Google Maps API reference.
+ */
+
+export function debugGeometryListeners(
+  geometries: Record<string, Geometry | Geometry[]>,
+  gMaps: typeof google.maps,
+) {
+  Object.values(geometries).forEach((entry) => {
+    const items = Array.isArray(entry) ? entry : [entry];
+
+    items.forEach((geometry) => {
+      const name = geometry.location?.name || geometry.location_name;
+      const id = geometry.location?.location_id || geometry.location_id;
+      const label = name || id;
+
+      console.group(`\nEvaluating geometry "${label}"`);
+
+      instanceTypes.forEach((instanceKey) => {
+        if (geometry[instanceKey]) {
+          console.log(`- Instance: ${instanceKey}`);
+
+          userEvents.forEach((eventName) => {
+            const hasListener = gMaps.event.hasListeners(geometry[instanceKey]!, eventName);
+            console.log(`   - ${eventName}: ${hasListener}`);
+          });
+        }
+      });
+      console.groupEnd();
+    });
+  });
+}
+
+/**
+ * Logs the event listeners attached to a specific instance object
+ *
+ * @param {google.maps.MVCObject} mapInstance - The map instance for which to log listeners.
+ * @param {typeof google.maps} gMaps - The Google Maps API reference.
+ * @param {string} [label='map instance'] - An optional label for identifying the instance.
+ */
+export function debugInstanceListeners(
+  mapInstance: google.maps.MVCObject,
+  gMaps: typeof google.maps,
+  label: string = 'map instance',
+) {
+  console.log(`\nEvaluating ${label} listeners:`);
+
+  eventTypes.forEach((eventName) => {
+    const hasListener = gMaps.event.hasListeners(mapInstance, eventName);
+    console.log(`- ${eventName}: ${hasListener}`);
+  });
+}


Reviews:
user: login: kathyavini
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T19:59:07Z
user: login: Duncan-Brain
body: I think this all makes sense, do I know how to evaluate it... not really haha.

I tried the debugger and saw things go from true without cleanup to false with cleanup. So it seems to be working! 

This brings back memories, I struggled with a few years ago too as I could not find the events in the browser event system, and the concept of a separate event system was foreign to me haha: https://stackoverflow.com/questions/76164400/how-do-i-view-active-google-maps-events-using-dev-tools

There is a link for one of googles maybe previous map API's with removeListeners described -- still no examples -- but at least there is some description about the methods there: [event listener system](https://developers.google.com/maps/documentation/javascript/reference/event)

Stuff is cleaned up and I see no functionality loss!
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-04-22T18:44:32Z
user: login: antsgar
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-04-23T17:28:07Z
user: login: kathyavini
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T20:52:29Z
user: login: Duncan-Brain
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-23T22:22:27Z
user: login: antsgar
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-24T13:22:17Z

Review comments:
diff_hunk: @@ -61,6 +61,13 @@ const useMapAssetRenderer = ({ isClickable, showingConfirmButtons, drawingState
   const [points, setPoints] = useState({});
 
   const [assetGeometries, setAssetGeometries] = useState(initAssetGeometriesState());
+  const assetGeometriesRef = useRef({});
path: packages/webapp/src/containers/Map/useMapAssetRenderer.js
user: login: kathyavini
body: Although conceptually it makes sense to use a ref object for cleanup rather than a state object (so the value at the time the `useEffect()` was created is irrelevant and the value used for cleanup is always the latest), I didn't in practice find any difference between using this ref and using `assetGeometries` directly.
created_at: 2025-04-17T19:59:07Z
author_association: COLLABORATOR
diff_hunk: @@ -185,11 +189,28 @@ export default function Map({ history, isCompactSideMenu }) {
       fullscreenControl: false,
     };
   };
-  const { drawAssets } = useMapAssetRenderer({
+  const { drawAssets, assetGeometriesRef, markerClusterRef } = useMapAssetRenderer({
     isClickable: !drawingState.type,
     drawingState: drawingState,
     showingConfirmButtons: showingConfirmButtons,
   });
+
+  // Cleanup listeners on map instance objects
+  useEffect(() => {
+    if (!gMaps) return;
+    return () => {
+      if (assetGeometriesRef.current) {
+        cleanupGeometryListeners(assetGeometriesRef.current, gMaps);
+      }
+      if (markerClusterRef.current) {
+        cleanupInstanceListeners(markerClusterRef.current, gMaps);
+      }
+      if (drawingState.drawingManager) {
+        cleanupInstanceListeners(drawingState.drawingManager, gMaps);
+      }
+    };
+  }, [gMap, gMaps]);
path: packages/webapp/src/containers/Map/index.jsx
user: login: antsgar
body: Is the `gMap` dependency needed?
created_at: 2025-04-22T18:29:17Z
author_association: COLLABORATOR
diff_hunk: @@ -185,11 +189,28 @@ export default function Map({ history, isCompactSideMenu }) {
       fullscreenControl: false,
     };
   };
-  const { drawAssets } = useMapAssetRenderer({
+  const { drawAssets, assetGeometriesRef, markerClusterRef } = useMapAssetRenderer({
     isClickable: !drawingState.type,
     drawingState: drawingState,
     showingConfirmButtons: showingConfirmButtons,
   });
+
+  // Cleanup listeners on map instance objects
+  useEffect(() => {
+    if (!gMaps) return;
+    return () => {
+      if (assetGeometriesRef.current) {
+        cleanupGeometryListeners(assetGeometriesRef.current, gMaps);
+      }
+      if (markerClusterRef.current) {
+        cleanupInstanceListeners(markerClusterRef.current, gMaps);
+      }
+      if (drawingState.drawingManager) {
+        cleanupInstanceListeners(drawingState.drawingManager, gMaps);
+      }
+    };
+  }, [gMap, gMaps]);
path: packages/webapp/src/containers/Map/index.jsx
user: login: kathyavini
body: No! Thank you, it was has a holdover from querying on the map with the debugger functions.
created_at: 2025-04-23T20:52:28Z
author_association: COLLABORATOR

PR comments:
user: login: antsgar
created_at: 2025-04-23T17:27:52Z
body: Thank you so much for working on this! 🙌 and also spotting that line of code in the Google Map React library, I didn't know they had any cleanup on their side.

There's some documentation from Google Maps API on event listener removal here https://developers.google.com/maps/documentation/javascript/events#removing, although not very extensive.

I tried checking the Performance tab with the memory chart instead of the Memory tab that I showed you guys yesterday, and I can see what looks like a leak after accessing the map even when running this branch

<img width="893" alt="Screenshot 2025-04-23 at 2 20 55 PM" src="https://github.com/user-attachments/assets/d12335a9-4938-4995-8b64-38af889ea1bc" />

After clicking on the map and back, there's a ton of nodes and listeners that aren't removed. However I don't know why that's still happening 🤷‍♀️ and digging into really old StackOverflow threads, I found this video from the Google Maps team https://www.youtube.com/watch?v=rUYs765QX-8&t=770s. It's really old but it seems they say that memory leaks will happen no matter what?

Anyway we'll just have to let it be, but this change hopefully improves the situation by clearing out what we can!
author_association: COLLABORATOR
user: login: kathyavini
created_at: 2025-04-23T20:52:15Z
body: @antsgar woah, that video is truly from another era 😮🤣

That's too bad that it didn't work in performance tab, but at least this is consistent with their advice... from 12 years ago 😁
author_association: COLLABORATOR