Skip to content

Commit 20a535f

Browse files
fix: use latest reuseMaps value in map instance cleanup (#1042)
reuseMaps was read directly from the effect's closure, but the effect intentionally excludes it from its dependency array (toggling it alone shouldn't recreate the map). As a result, if reuseMaps changed between mount and unmount, the cleanup used the stale value captured at mount, either leaking an instance onto the static cache stack or discarding one that should have been cached. Track it in a ref that's kept current via its own effect, and read that ref in both the create and cleanup paths instead. --------- Co-authored-by: Martin Schuhfuss <m.schuhfuss@gmail.com>
1 parent 1b4e0cc commit 20a535f

2 files changed

Lines changed: 102 additions & 3 deletions

File tree

src/components/__tests__/map.test.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ beforeEach(() => {
4444
constructor(...args: ConstructorParameters<typeof google.maps.Map>) {
4545
createMapSpy(...args);
4646
super(...args);
47+
this.getDiv = jest.fn().mockImplementation(() => args[0]);
4748
}
4849
};
4950

@@ -245,6 +246,97 @@ describe('map instance caching', () => {
245246
// a fresh map instance should have been created instead of reusing the broken one
246247
expect(createMapSpy).toHaveBeenCalled();
247248
});
249+
250+
test('respects reuseMaps being turned off before unmount, even without other prop changes', async () => {
251+
// mapId/renderingType/colorScheme stay constant, so the effect that reads
252+
// reuseMaps is intentionally not re-run when only reuseMaps changes. The
253+
// cleanup must still see the latest reuseMaps value instead of the one
254+
// captured when the map was created: with reuseMaps now false, unmounting
255+
// must clear the instance's listeners instead of pushing it onto the
256+
// internal (unbounded, never-reused) cache stack.
257+
const center = {lat: 53.55, lng: 10.05};
258+
259+
const {rerender, unmount} = render(
260+
<GoogleMap mapId={'toggle-reuse'} reuseMaps center={center} zoom={12} />,
261+
{wrapper}
262+
);
263+
await waitFor(() => expect(screen.getByTestId('map')).toBeInTheDocument());
264+
const mapInstance = mockInstances.get(google.maps.Map).at(-1)!;
265+
266+
rerender(
267+
<GoogleMap
268+
mapId={'toggle-reuse'}
269+
reuseMaps={false}
270+
center={center}
271+
zoom={12}
272+
/>
273+
);
274+
275+
// note: checking for clearInstanceListeners being called is an implementation
276+
// detail that shouldn't be in this test, but it's the only way we can tell
277+
// if the map instance was discarded or kept around.
278+
jest.mocked(google.maps.event.clearInstanceListeners).mockClear();
279+
unmount();
280+
281+
expect(google.maps.event.clearInstanceListeners).toHaveBeenCalledWith(
282+
mapInstance
283+
);
284+
});
285+
286+
test('respects reuseMaps being turned on before unmount, even without other prop changes', async () => {
287+
// mapId/renderingType/colorScheme stay constant, so the effect that reads
288+
// reuseMaps is intentionally not re-run when only reuseMaps changes. The
289+
// cleanup must still see the latest reuseMaps value instead of the one
290+
// captured when the map was created: with reuseMaps now true, unmounting
291+
// must push the instance onto the cache stack so it can be reused on remount.
292+
const center = {lat: 53.55, lng: 10.05};
293+
294+
const {rerender, unmount} = render(
295+
<GoogleMap
296+
mapId={'toggle-reuse-on'}
297+
reuseMaps={false}
298+
center={center}
299+
zoom={12}
300+
/>,
301+
{wrapper}
302+
);
303+
await waitFor(() => expect(screen.getByTestId('map')).toBeInTheDocument());
304+
const mapInstance = mockInstances.get(google.maps.Map).at(-1)!;
305+
306+
rerender(
307+
<GoogleMap
308+
mapId={'toggle-reuse-on'}
309+
reuseMaps
310+
center={center}
311+
zoom={12}
312+
/>
313+
);
314+
315+
// note: checking for clearInstanceListeners being called is an implementation
316+
// detail that shouldn't be in this test, but it's the only way we can tell
317+
// if the map instance was discarded or kept around.
318+
jest.mocked(google.maps.event.clearInstanceListeners).mockClear();
319+
unmount();
320+
321+
expect(google.maps.event.clearInstanceListeners).not.toHaveBeenCalledWith(
322+
mapInstance
323+
);
324+
325+
createMapSpy.mockClear();
326+
render(
327+
<GoogleMap
328+
mapId={'toggle-reuse-on'}
329+
reuseMaps
330+
center={center}
331+
zoom={12}
332+
/>,
333+
{wrapper}
334+
);
335+
await waitFor(() => expect(screen.getByTestId('map')).toBeInTheDocument());
336+
337+
expect(createMapSpy).not.toHaveBeenCalled();
338+
expect(mockInstances.get(google.maps.Map).at(-1)).toBe(mapInstance);
339+
});
248340
});
249341

250342
describe('camera configuration', () => {

src/components/map/use-map-instance.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ export function useMapInstance(
124124
cameraState: CameraState;
125125
}>(undefined);
126126

127+
// kept up to date on every render so the effect below (which intentionally
128+
// doesn't list reuseMaps as a dependency) always sees the latest value,
129+
// both when creating a map and in its cleanup function on unmount.
130+
const reuseMapsRef = useRef(reuseMaps);
131+
useEffect(() => {
132+
reuseMapsRef.current = reuseMaps;
133+
}, [reuseMaps]);
134+
127135
// create the map instance and register it in the context
128136
useEffect(
129137
() => {
@@ -143,7 +151,7 @@ export function useMapInstance(
143151
// correctly). In that case `getDiv()` doesn't return a usable DOM node,
144152
// so we have to discard the cached instance instead of trying to reuse it.
145153
const cachedMap =
146-
reuseMaps && CachedMapStack.has(cacheKey)
154+
reuseMapsRef.current && CachedMapStack.has(cacheKey)
147155
? (CachedMapStack.pop(cacheKey) as google.maps.Map)
148156
: null;
149157
const cachedMapDiv = cachedMap?.getDiv();
@@ -181,7 +189,6 @@ export function useMapInstance(
181189
});
182190
}
183191

184-
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional to sync the imperative instance with state
185192
setMap(map);
186193
addMapInstance(map, id);
187194

@@ -214,7 +221,7 @@ export function useMapInstance(
214221
// detach the map-div from the dom
215222
mapDiv.remove();
216223

217-
if (reuseMaps) {
224+
if (reuseMapsRef.current) {
218225
// push back on the stack
219226
CachedMapStack.push(cacheKey, map);
220227
} else {

0 commit comments

Comments
 (0)