From fa71ae97fb2aae62790064bcac879c0cbbc96d65 Mon Sep 17 00:00:00 2001 From: Tomas Prokop Date: Thu, 23 Jul 2026 18:32:27 +0200 Subject: [PATCH] Fix infinite update loop crashing the map (React error #185) MediaMarkers.tsx was passing an inline arrow function as markerRef, giving it a new identity on every render. useClusterer relies on ref callback identity staying stable per marker (documented in its own comment) - a fresh function each render makes React detach/reattach every AdvancedMarker's ref on every render, which triggers the setMarkers state update in a loop and blows past React's max update depth, crashing the whole page with any geotagged media on the map. Fixed by caching one stable ref-callback function per post id (same pattern useClusterer itself uses internally), reading the post to tag onto the marker from a ref instead of capturing it in a new closure. Verified: npm run build, npm run lint. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/components/RouteMap/MediaMarkers.tsx | 43 +++++++++++++++++------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/components/RouteMap/MediaMarkers.tsx b/src/components/RouteMap/MediaMarkers.tsx index b0bf499..9d34a18 100644 --- a/src/components/RouteMap/MediaMarkers.tsx +++ b/src/components/RouteMap/MediaMarkers.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMap } from '@vis.gl/react-google-maps'; +import type { Marker } from '@googlemaps/markerclusterer'; import { useMediaPosts, type MediaPost } from '../../hooks/useMediaPosts'; import MediaMarker from './MediaMarker'; import MediaLightbox from '../MediaLightbox'; @@ -14,21 +15,37 @@ export default function MediaMarkers() { const map = useMap(); const setMediaMarkerRef = useClusterer(map, mediaClusterRenderer); + // Keeps the tagging ref callback below stable in identity (see caveat + // on useClusterer: a new function per render causes React to detach/ + // reattach every AdvancedMarker's ref on every single render, which + // triggers an infinite setState loop in useClusterer). The post is + // read from this ref instead of being captured directly so the + // wrapper function itself never needs to change. + const postsById = useRef>({}); + useEffect(() => { + const next: Record = {}; + for (const post of geotagged) next[post.id] = post; + postsById.current = next; + }, [geotagged]); + + const taggingRefs = useRef void>>({}); + function getTaggingRef(id: string) { + if (!taggingRefs.current[id]) { + taggingRefs.current[id] = (marker: Marker | null) => { + // Tag the raw marker instance with its post so the cluster + // renderer (which only receives marker instances, not our + // React props) can pick a thumbnail to show on the bubble. + if (marker) Object.assign(marker, { __mediaPost: postsById.current[id] }); + setMediaMarkerRef(id)(marker); + }; + } + return taggingRefs.current[id]; + } + return ( <> {geotagged.map((post) => ( - setSelected(post)} - markerRef={(marker) => { - // Tag the raw marker instance with its post so the cluster - // renderer (which only receives marker instances, not our - // React props) can pick a thumbnail to show on the bubble. - if (marker) Object.assign(marker, { __mediaPost: post }); - setMediaMarkerRef(post.id)(marker); - }} - /> + setSelected(post)} markerRef={getTaggingRef(post.id)} /> ))} {selected && setSelected(null)} />}