You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Due for payment 2026-06-25] [$250] [Sentry: APP-MR] TypeError properties.get in mapbox-gl on distance-map step (web) #91294
Looks like a Mapbox GL teardown / re-render race. A setTimeout-scheduled fog-opacity recomputation runs against the map's state after state.properties has been torn down (the distance-map step unmounted, the user navigated, the map was .remove()-ed, etc.). Possible sources on our side:
useEffect cleanup ordering in our Map wrapper that disposes the map before clearing scheduled paint callbacks
A re-init flow that swaps state without clearing pending tasks
Investigation suggestions
Audit our Map / DistanceMap wrapper (probably src/components/MapView/...) for: (a) setTimeout scheduling Mapbox calls without storing the handle for cleanup, (b) any code path that nulls state.properties directly.
Check whether bumping mapbox-gl to a newer minor resolves it; the getOpacityAtLatLng path has been touched in recent Mapbox releases.
Try to reproduce by rapidly entering and leaving the distance-map step, especially with fog enabled.
Reproduction
Unknown deterministically. Frequency suggests narrow timing window.
Related
Possibly the same flow as App#88235 (same distance-map area, no Sentry link in its body — triage can decide whether to merge).
A 60ms race window in mapbox-gl's Marker class causes the crash. When fog is active on the map, each marker schedules setTimeout(this._evaluateOpacity.bind(this), 60) to recompute fog opacity. When the user navigates away from the distance-map step, React unmounts the MapViewImpl component, which triggers react-map-gl's cleanup → map.remove(). However, map.remove() calls style.destroy() (which does not clean up the fog object or its properties) and then fires the 'remove' event. If the 60ms timer has already been dequeued by the JS event loop before clearTimeout runs, marker._evaluateOpacity() executes against a partially-destroyed map: the marker's _map reference is still set (it only gets nulled in marker.remove(), which map.remove() does not call per-marker), so the guard passes, and the call chain reaches fog.state → this.properties.get('range') where properties is undefined.
The Expensify MapViewImpl.web.tsx has no explicit map or marker cleanup — it relies entirely on react-map-gl's unmount handler, which is insufficient to prevent this race.
What changes do you think we should make in order to solve the problem?
Add a useEffect cleanup in MapViewImpl.web.tsx that, on unmount, explicitly removes all markers from the map before react-map-gl destroys it. This ensures each marker's _fadeTimer is cleared via marker.remove() → _clearFadeTimer(), closing the race window.
Concretely, add a cleanup effect that calls mapRef.getMap().remove() (or iterates markers to remove them) before the react-map-gl<Map> component's own cleanup runs. Alternatively, wrap the mapbox-gl _queryFogOpacity call site with a defensive guard — but since that's inside node_modules, the cleaner approach is to ensure our component cleans up markers properly on unmount.
What alternative solutions did you explore? (Optional)
Bump mapbox-gl version — newer releases may have added guards in _evaluateOpacity or getOpacityAtLatLng, but this is a large dependency upgrade with its own risk surface and doesn't address the fundamental cleanup gap on our side.
Patch mapbox-gl via patch-package — add a if (!this.properties) return guard in fog.js's state getter. This is a targeted fix but couples us to patching internals that may shift between versions.
Investigation details
Stack trace analysis:
marker._evaluateOpacity() (marker.js:446) → map._queryFogOpacity() (map.js:2756) → fog.getOpacityAtLatLng() → fog.state getter (fog.js:57) → this.properties.get('range') crashes because properties is undefined
Key evidence:
marker.js:594-596: Markers schedule a 60ms setTimeout for opacity evaluation when fog/globe/terrain is active
marker.js:448: The guard only checks if (!map) return — but the map reference is still valid during teardown
map.js:3516-3573: map.remove() clears task queues and destroys style, but does NOT iterate markers to clear their fade timers
style.js:1873-1879: style.destroy() only handles worker caches and terrain — NOT fog
fog.js:40-55: properties is only populated by recalculate(), not in the constructor — it can be undefined if fog is in a partially-destroyed state
MapViewImpl.web.tsx: The only cleanup is a ResizeObserver disconnect (line 182). No marker or map cleanup exists
Related issue:#88235 is in the same distance-map area — it may share the same underlying race condition but its symptoms (unexpected error after cache clear) suggest a different trigger path.
Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.
Please re-state the problem that we are trying to solve in this issue.
On web, the distance-map step can throw a Mapbox GL TypeError from a delayed marker/fog opacity callback after or during map initialization/teardown. The user-visible symptom is a Sentry-only crash/noise in the distance-map flow rather than a deterministic form validation error.
What is the root cause of that problem?
DistanceMapView on web is a thin pass-through to MapView, and MapViewImpl.web.tsx mounts the react-map-gl<Map> with all marker children immediately: the current-location marker, distance-label marker, waypoint markers, and the Direction child are rendered in the first map render.
Mapbox GL 2.15 schedules marker opacity recalculation with setTimeout whenever map.getFog() is truthy. The Expensify style URL used by the distance map can include fog, so a marker mounted before the style/fog transition has fully recalculated can run Mapbox's delayed _evaluateOpacity -> _queryFogOpacity -> fog.getOpacityAtLatLng path while fog.properties is still unset. That matches the Sentry frame where Mapbox reads state.properties.get('range') with no properties.
The trigger is narrow because it depends on timing: entering/leaving /distance-map, style load timing, marker creation, and Mapbox's delayed DOM task. React error boundaries do not catch it because the throw happens from Mapbox's scheduled browser callback, not during React render.
What changes do you think we should make in order to solve the problem?
In src/components/MapView/MapViewImpl.web.tsx, track whether the Mapbox map/style has finished loading before mounting children that create Mapbox markers or sources/layers.
Concretely:
Add an isMapLoaded state in MapViewImpl.
Reset it to false when the map instance, styleURL, or accessToken changes.
Pass onLoad={() => setIsMapLoaded(true)} to <Map>.
Render the current-position marker, distance-label marker, waypoint markers, and Direction only when isMapLoaded is true.
Keep the outer <Map> and the center button behavior intact; the button can either stay hidden until isMapLoaded or no-op through the existing mapRef guard.
This fixes the race at the app boundary: we no longer create Mapbox markers while Mapbox is still building/evaluating the style fog object, so Mapbox's delayed marker opacity timer cannot observe a half-initialized fog state.
Scope boundaries:
Files touched (with call sites): src/components/MapView/MapViewImpl.web.tsx — MapViewImpl map render and child marker/source rendering around lines 255-313.
Platforms: web/desktop web only, through MapViewImpl.web.tsx.
What alternative solutions did you explore? (Optional)
Upgrade mapbox-gl from 2.15.0. That may also contain upstream hardening, but this repo currently uses react-map-gl 7.1.3 with mapbox-gl 2.x, and jumping to Mapbox GL 3.x is a broader dependency change with rendering/licensing/regression risk. The narrow app-side guard is still needed because it prevents us from handing half-loaded style state to marker children.
Catch or ignore the Sentry error globally. That would reduce noise but leave the actual scheduled callback crash in place, and it would hide similar Mapbox lifecycle errors. The better fix is to avoid mounting the Mapbox children until the style is ready.
ResizeObserver notifications are queued and delivered at a paint boundary, not synchronously when the size change happens. When the user leaves the distance-map step (back nav, RHP swap, or any route change), three things race:
react-map-gl's <Map> child unmounts and calls map.remove() — which sets map._removed = true and tears down style, painter, and the source-layer property tables that back state.properties.
The browser still flushes any ResizeObserver entry that was queued beforedisconnect() ran (Chromium and WebKit both keep already-dispatched batches alive — disconnect() only prevents new observations from being queued, not the in-flight delivery).
The unmount sequence reliably produces a queued resize entry (the distance-map's container collapses to 0×0 as the route is dismounted), so this last category fires after the map has been removed. Our callback then runs mapRef.resize(), which posts an internal render task. That render task is what the Sentry stack captures:
getOpacityAtLatLng reaches into style._mergedLayers[...].properties during fog-opacity evaluation; after map.remove() the style has been disposed, so the lookup hits undefined.get(...). The auto.browser.browserapierrors.setTimeout mechanism on the Sentry trace is consistent with mapbox-gl scheduling its repaint via setTimeout(0).
This explains every part of the report:
Web only: native uses @rnmapbox/maps, which doesn't go through our ResizeObserver path.
distance-map route only: it's the only screen that mounts MapViewImpl.web.tsxand gets dismounted abruptly when the user navigates back from a sub-step. The chat-message map preview rarely unmounts while still resizing.
Narrow timing window / low frequency: the queued entry has to land in the small slice between our disconnect() and react-map-gl's map.remove(). Faster machines/networks reduce the window; slow paint cycles (lots of waypoints, low-end hardware) widen it.
First seen 2025-11-05: aligns with the mapbox-gl bump that moved fog evaluation into the paint loop on every camera/size change. Before that, resize() didn't trigger fog opacity recomputation, so the same race didn't reach this code path.
I also looked at issue [App#88235](#88235) and I'm fairly confident it's the same bug — same component, same teardown window, same getOpacityAtLatLng neighbourhood. Worth linking them so a fix here closes both.
What changes do you think we should make in order to solve the problem?
Make the ResizeObserver callback a no-op when the map has already been torn down. react-map-gl's MapRef.getMap() returns the underlying mapbox-gl Map instance, which exposes _removed: true after remove() has been called — the cheapest possible check:
useEffect(() => {
if (!mapRef) {
return;
}
- const resizeObserver = new ResizeObserver(() => {- mapRef.resize();- setShouldResetBoundaries(true);- });+ const resizeObserver = new ResizeObserver(() => {+ const map = mapRef.getMap();+ // ResizeObserver can deliver a queued entry between our disconnect() and+ // react-map-gl's map.remove(); resizing a removed map schedules a paint+ // that crashes inside fog-opacity evaluation (Sentry APP-MR).+ if (!map || map._removed) {+ return;+ }+ map.resize();+ setShouldResetBoundaries(true);+ });
resizeObserver.observe(mapRef.getContainer());
return () => {
resizeObserver.disconnect();
};
}, [mapRef]);
The _removed field is a public-enough convention (mapbox-gl uses it internally for the same purpose — every method on Map short-circuits when it's true), and it's the same flag react-map-gl uses to gate its own teardown. If we want to avoid touching an underscore-prefixed field, an equally good guard is !map.getStyle() — getStyle() returns undefined after remove(). Either works; _removed is cheaper.
I'd pair this with a second small hardening pass on the resize side — when we later read setShouldResetBoundaries, [resetBoundaries](https://github.com/Expensify/App/blob/main/src/components/MapView/MapViewImpl.web.tsx#L132-L156) calls mapRef.getMap() and then map.fitBounds(...) unconditionally. React will drop the state-update from an unmounted component so this path is mostly safe, but the same if (!map || map._removed) return; belongs there for defense in depth. One line.
That's the full change. Nothing else in this file calls into the map from a callback that can outlive the unmount — I checked the other useEffects and the useImperativeHandle paths. The useImperativeHandle exposes flyTo/fitBounds but they're guarded by mapRef?.flyTo which becomes a no-op when the ref is nulled by react-map-gl's own cleanup.
What alternative solutions did you explore?
Track our own mountedRef and bail out when it's false. Works for the React-state-update half, but doesn't help with mapRef.resize() — by the time our cleanup runs and flips mountedRef, the resize entry has already been queued; the callback still fires later with mountedRef === false, which is fine, but checking mountedRef instead of _removed is less precise. The map can be removed without our component unmounting (e.g., react-map-gl re-creating it on mapLib/accessToken change), in which case mountedRef is still true and we'd still crash. _removed checks the actual condition that matters.
requestAnimationFrame-defer the resize() call so it lines up with paint. Doesn't fix anything — the crash is in paint, after resize() schedules it. Deferring just makes the window slightly larger.
Bump mapbox-gl to a minor that fixes _queryFogOpacity defensively. The Sentry write-up suggests this. Upstream did harden getOpacityAtLatLng in a recent minor, but I'd rather not gate the fix on a dependency bump when our own code is creating the dangling call. Worth doing as a follow-up but not as the primary fix.
Disable fog on this style. Cosmetically removes the symptom and avoids the crash, but only papers over the underlying "we call into a removed map" problem — any future Mapbox internal that touches state.properties from a paint task would re-introduce it.
Please re-state the problem that we are trying to solve in this issue.
On the distance-map step (/create/:action/start/:iouType/:transactionID/distance-new/distance-map), an uncaught TypeError is thrown from inside Mapbox GL: state.properties.get('range') where properties is undefined. It is filed from a setTimeout-scheduled callback (Sentry mechanism auto.browser.browserapierrors.setTimeout), with the stack Marker._evaluateOpacity → Map._queryFogOpacity → Fog.getOpacityAtLatLng → Fog state getter. It has affected ~448 users and is ongoing on web.
What is the root cause of that problem?
The crash lives in Mapbox GL's marker fog-opacity fade logic, and it is reachable only because the distance map renders with fog enabled.
When the map has fog (our custom style mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq ships a fog layer), every Marker schedules a deferred opacity re-evaluation via setTimeout(this._evaluateOpacity, 60). This only happens when map.getFog() (or globe/terrain) is truthy:
We render several markers inside <Map> (current-position dot, the distance label, and each waypoint), so these fade timers are scheduled whenever the markers update (which happens during the flyTo/fitBounds animations the component triggers).
The 60ms timer is the problem. If the user leaves the distance-map step (the component unmounts, the map is torn down, the style/fog is reset) while a fade timer is still pending, the timer fires against a Fog object whose properties field has not been (re)computed. In Mapbox GL, Fog.properties is only assigned in recalculate(); a freshly constructed or torn-down Fog has properties === undefined. The deferred _evaluateOpacity then calls _queryFogOpacity → fog.getOpacityAtLatLng → the Fogstate getter, which does this.properties.get('range') on the undefined properties → the exact TypeError in the report.
Because the throw happens inside an async setTimeout callback, the ErrorBoundary around the map cannot catch it, so it escapes to window.onerror and is reported to Sentry:
The deeper point: fog is a 3D atmospheric effect that is only ever visible when the map is pitched or shown as a globe at very low zoom. Our distance map is a flat, top-down, street-level view (default and single-marker zoom are both 15) — fog is never visible here. So fog provides zero value on this screen while being the sole reason the crashing code path is reachable.
What changes do you think we should make in order to solve the problem?
Disable fog on the distance map by passing fog={null} to the react-map-gl<Map> component in MapViewImpl.web.tsx.
react-map-gl already supports a declarative fog prop: on every styledata/sourcedata event it diffs the prop and calls map.setFog(...) once the style is loaded. Passing fog={null} makes it call map.setFog(null), which deletes style.fog. With fog gone:
Markers no longer schedule the deferred fog fade-opacity timer (map.getFog() is now falsy), so the teardown race window disappears.
Even if a timer were scheduled for another reason, _queryFogOpacity short-circuits early when style.fog is absent and never touches fog.properties, so the TypeError can no longer occur.
This is a one-prop, declarative change that re-applies automatically on any style reload (because react-map-gl re-runs its style-component sync on styledata), and it has no visual impact on the flat, street-level distance map since fog is never rendered at that zoom/pitch.
What alternative solutions did you explore? (Optional)
Calling map.setFog(null) imperatively in a <Map>onLoad handler. This works, but onLoad fires only once, so it would not re-apply if the style reloads; the declarative fog={null} prop is re-applied by react-map-gl on every styledata event and is more robust.
Guarding/patching the marker opacity path inside mapbox-gl via patch-package. This touches vendored code and is harder to maintain; removing the unused fog at our layer addresses the same root cause without modifying the dependency.
Bumping mapbox-gl to a newer minor. This carries a large, app-wide regression surface for an unverified fix, whereas disabling unused fog on this one screen is minimal and targeted.
changed the title [-][Sentry: APP-MR] TypeError properties.get in mapbox-gl on distance-map step (web)[/-][+][$250] [Sentry: APP-MR] TypeError properties.get in mapbox-gl on distance-map step (web)[/+]on May 21, 2026
[Contributor] The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.
We updated mapbox-gl library here
[Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.
Link to discussion: NA
[Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.
Regression Test Proposal
Test:
Open a workspace chat → green + → Create expense → Distance, and open the Map tab.
Map renders: the map loads as a flat, top-down street map centered on your location — no blank/grey tiles and no error.
Waypoints + route: set a Start and a Stop (search an address, use current location, or tap the map). The map should fit to the waypoints, show the pins, draw a route line between them, and update the distance/amount.
Add an intermediate Stop, then remove one — the route line and distance should update each time.
Interactions: pan and zoom the map; it should stay responsive and keep loading tiles.
Save & view: complete the expense, open it, and confirm the route map renders on the expense detail view. Then edit a waypoint and confirm the map updates.
Leave & return (the crash this PR fixes): open the Map step, leave it (back/close), and re-open it several times — the app must never crash and the map must keep rendering.
Activity
MelvinBot commentedon May 21, 2026
Proposal
What is the root cause of that problem?
A 60ms race window in mapbox-gl's
Markerclass causes the crash. When fog is active on the map, each marker schedulessetTimeout(this._evaluateOpacity.bind(this), 60)to recompute fog opacity. When the user navigates away from the distance-map step, React unmounts theMapViewImplcomponent, which triggersreact-map-gl's cleanup →map.remove(). However,map.remove()callsstyle.destroy()(which does not clean up thefogobject or itsproperties) and then fires the'remove'event. If the 60ms timer has already been dequeued by the JS event loop beforeclearTimeoutruns,marker._evaluateOpacity()executes against a partially-destroyed map: the marker's_mapreference is still set (it only gets nulled inmarker.remove(), whichmap.remove()does not call per-marker), so the guard passes, and the call chain reachesfog.state→this.properties.get('range')wherepropertiesisundefined.The Expensify
MapViewImpl.web.tsxhas no explicit map or marker cleanup — it relies entirely onreact-map-gl's unmount handler, which is insufficient to prevent this race.What changes do you think we should make in order to solve the problem?
Add a
useEffectcleanup inMapViewImpl.web.tsxthat, on unmount, explicitly removes all markers from the map beforereact-map-gldestroys it. This ensures each marker's_fadeTimeris cleared viamarker.remove()→_clearFadeTimer(), closing the race window.Concretely, add a cleanup effect that calls
mapRef.getMap().remove()(or iterates markers to remove them) before thereact-map-gl<Map>component's own cleanup runs. Alternatively, wrap the mapbox-gl_queryFogOpacitycall site with a defensive guard — but since that's insidenode_modules, the cleaner approach is to ensure our component cleans up markers properly on unmount.What alternative solutions did you explore? (Optional)
mapbox-glversion — newer releases may have added guards in_evaluateOpacityorgetOpacityAtLatLng, but this is a large dependency upgrade with its own risk surface and doesn't address the fundamental cleanup gap on our side.mapbox-glviapatch-package— add aif (!this.properties) returnguard infog.js'sstategetter. This is a targeted fix but couples us to patching internals that may shift between versions.Investigation details
Stack trace analysis:
marker._evaluateOpacity()(marker.js:446) →map._queryFogOpacity()(map.js:2756) →fog.getOpacityAtLatLng()→fog.stategetter (fog.js:57) →this.properties.get('range')crashes becausepropertiesisundefinedKey evidence:
marker.js:594-596: Markers schedule a 60mssetTimeoutfor opacity evaluation when fog/globe/terrain is activemarker.js:448: The guard only checksif (!map) return— but the map reference is still valid during teardownmap.js:3516-3573:map.remove()clears task queues and destroys style, but does NOT iterate markers to clear their fade timersstyle.js:1873-1879:style.destroy()only handles worker caches and terrain — NOT fogfog.js:40-55:propertiesis only populated byrecalculate(), not in the constructor — it can beundefinedif fog is in a partially-destroyed stateMapViewImpl.web.tsx: The only cleanup is aResizeObserverdisconnect (line 182). No marker or map cleanup existsRelated issue: #88235 is in the same distance-map area — it may share the same underlying race condition but its symptoms (unexpected error after cache clear) suggest a different trigger path.
Next Steps for Contributor+ team: Reply with
@MelvinBot implement thisto create a draft PR,@MelvinBot <your feedback>to refine this analysis, or explain why you are rejecting Melvin's proposal.KJ21-ENG commentedon May 21, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On web, the distance-map step can throw a Mapbox GL
TypeErrorfrom a delayed marker/fog opacity callback after or during map initialization/teardown. The user-visible symptom is a Sentry-only crash/noise in the distance-map flow rather than a deterministic form validation error.What is the root cause of that problem?
DistanceMapViewon web is a thin pass-through toMapView, andMapViewImpl.web.tsxmounts thereact-map-gl<Map>with all marker children immediately: the current-location marker, distance-label marker, waypoint markers, and theDirectionchild are rendered in the first map render.Mapbox GL 2.15 schedules marker opacity recalculation with
setTimeoutwhenevermap.getFog()is truthy. The Expensify style URL used by the distance map can include fog, so a marker mounted before the style/fog transition has fully recalculated can run Mapbox's delayed_evaluateOpacity -> _queryFogOpacity -> fog.getOpacityAtLatLngpath whilefog.propertiesis still unset. That matches the Sentry frame where Mapbox readsstate.properties.get('range')with noproperties.The trigger is narrow because it depends on timing: entering/leaving
/distance-map, style load timing, marker creation, and Mapbox's delayed DOM task. React error boundaries do not catch it because the throw happens from Mapbox's scheduled browser callback, not during React render.What changes do you think we should make in order to solve the problem?
In
src/components/MapView/MapViewImpl.web.tsx, track whether the Mapbox map/style has finished loading before mounting children that create Mapbox markers or sources/layers.Concretely:
isMapLoadedstate inMapViewImpl.falsewhen the map instance,styleURL, oraccessTokenchanges.onLoad={() => setIsMapLoaded(true)}to<Map>.Directiononly whenisMapLoadedis true.<Map>and the center button behavior intact; the button can either stay hidden untilisMapLoadedor no-op through the existingmapRefguard.This fixes the race at the app boundary: we no longer create Mapbox markers while Mapbox is still building/evaluating the style fog object, so Mapbox's delayed marker opacity timer cannot observe a half-initialized fog state.
Scope boundaries:
src/components/MapView/MapViewImpl.web.tsx—MapViewImplmap render and child marker/source rendering around lines 255-313.MapViewImpl.web.tsx.src/components/MapView/MapView.tsx, AndroidDistanceMapView, token fetching insrc/libs/actions/MapboxToken.ts, distance request submission logic, route fetching.What alternative solutions did you explore? (Optional)
Upgrade
mapbox-glfrom 2.15.0. That may also contain upstream hardening, but this repo currently usesreact-map-gl7.1.3 withmapbox-gl2.x, and jumping to Mapbox GL 3.x is a broader dependency change with rendering/licensing/regression risk. The narrow app-side guard is still needed because it prevents us from handing half-loaded style state to marker children.Catch or ignore the Sentry error globally. That would reduce noise but leave the actual scheduled callback crash in place, and it would hide similar Mapbox lifecycle errors. The better fix is to avoid mounting the Mapbox children until the style is ready.
Abdukhamid000 commentedon May 21, 2026
Proposal
What is the root cause of that problem?
There's one place in our Mapbox wrapper that calls into the map from a callback that can fire after the map has been removed: the
ResizeObservereffect at [MapViewImpl.web.tsx:170-184](https://github.com/Expensify/App/blob/main/src/components/MapView/MapViewImpl.web.tsx#L170-L184):ResizeObservernotifications are queued and delivered at a paint boundary, not synchronously when the size change happens. When the user leaves the distance-map step (back nav, RHP swap, or any route change), three things race:useEffectcleanup runsresizeObserver.disconnect().react-map-gl's<Map>child unmounts and callsmap.remove()— which setsmap._removed = trueand tears downstyle,painter, and the source-layer property tables that backstate.properties.ResizeObserverentry that was queued beforedisconnect()ran (Chromium and WebKit both keep already-dispatched batches alive —disconnect()only prevents new observations from being queued, not the in-flight delivery).The unmount sequence reliably produces a queued resize entry (the distance-map's container collapses to 0×0 as the route is dismounted), so this last category fires after the map has been removed. Our callback then runs
mapRef.resize(), which posts an internal render task. That render task is what the Sentry stack captures:getOpacityAtLatLngreaches intostyle._mergedLayers[...].propertiesduring fog-opacity evaluation; aftermap.remove()the style has been disposed, so the lookup hitsundefined.get(...). Theauto.browser.browserapierrors.setTimeoutmechanism on the Sentry trace is consistent with mapbox-gl scheduling its repaint viasetTimeout(0).This explains every part of the report:
@rnmapbox/maps, which doesn't go through our ResizeObserver path.MapViewImpl.web.tsxand gets dismounted abruptly when the user navigates back from a sub-step. The chat-message map preview rarely unmounts while still resizing.disconnect()andreact-map-gl'smap.remove(). Faster machines/networks reduce the window; slow paint cycles (lots of waypoints, low-end hardware) widen it.resize()didn't trigger fog opacity recomputation, so the same race didn't reach this code path.I also looked at issue [App#88235](#88235) and I'm fairly confident it's the same bug — same component, same teardown window, same
getOpacityAtLatLngneighbourhood. Worth linking them so a fix here closes both.What changes do you think we should make in order to solve the problem?
Make the
ResizeObservercallback a no-op when the map has already been torn down.react-map-gl'sMapRef.getMap()returns the underlying mapbox-glMapinstance, which exposes_removed: trueafterremove()has been called — the cheapest possible check:useEffect(() => { if (!mapRef) { return; } - const resizeObserver = new ResizeObserver(() => { - mapRef.resize(); - setShouldResetBoundaries(true); - }); + const resizeObserver = new ResizeObserver(() => { + const map = mapRef.getMap(); + // ResizeObserver can deliver a queued entry between our disconnect() and + // react-map-gl's map.remove(); resizing a removed map schedules a paint + // that crashes inside fog-opacity evaluation (Sentry APP-MR). + if (!map || map._removed) { + return; + } + map.resize(); + setShouldResetBoundaries(true); + }); resizeObserver.observe(mapRef.getContainer()); return () => { resizeObserver.disconnect(); }; }, [mapRef]);The
_removedfield is a public-enough convention (mapbox-gl uses it internally for the same purpose — every method onMapshort-circuits when it's true), and it's the same flag react-map-gl uses to gate its own teardown. If we want to avoid touching an underscore-prefixed field, an equally good guard is!map.getStyle()—getStyle()returnsundefinedafterremove(). Either works;_removedis cheaper.I'd pair this with a second small hardening pass on the resize side — when we later read
setShouldResetBoundaries,[resetBoundaries](https://github.com/Expensify/App/blob/main/src/components/MapView/MapViewImpl.web.tsx#L132-L156)callsmapRef.getMap()and thenmap.fitBounds(...)unconditionally. React will drop the state-update from an unmounted component so this path is mostly safe, but the sameif (!map || map._removed) return;belongs there for defense in depth. One line.That's the full change. Nothing else in this file calls into the map from a callback that can outlive the unmount — I checked the other
useEffects and theuseImperativeHandlepaths. TheuseImperativeHandleexposesflyTo/fitBoundsbut they're guarded bymapRef?.flyTowhich becomes a no-op when the ref is nulled by react-map-gl's own cleanup.What alternative solutions did you explore?
mountedRefand bail out when it's false. Works for the React-state-update half, but doesn't help withmapRef.resize()— by the time our cleanup runs and flipsmountedRef, the resize entry has already been queued; the callback still fires later withmountedRef === false, which is fine, but checkingmountedRefinstead of_removedis less precise. The map can be removed without our component unmounting (e.g., react-map-gl re-creating it onmapLib/accessTokenchange), in which casemountedRefis stilltrueand we'd still crash._removedchecks the actual condition that matters.requestAnimationFrame-defer theresize()call so it lines up with paint. Doesn't fix anything — the crash is in paint, afterresize()schedules it. Deferring just makes the window slightly larger.mapbox-glto a minor that fixes_queryFogOpacitydefensively. The Sentry write-up suggests this. Upstream did hardengetOpacityAtLatLngin a recent minor, but I'd rather not gate the fix on a dependency bump when our own code is creating the dangling call. Worth doing as a follow-up but not as the primary fix.state.propertiesfrom a paint task would re-introduce it.wildan-m commentedon May 21, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On the distance-map step (
/create/:action/start/:iouType/:transactionID/distance-new/distance-map), an uncaughtTypeErroris thrown from inside Mapbox GL:state.properties.get('range')wherepropertiesisundefined. It is filed from asetTimeout-scheduled callback (Sentry mechanismauto.browser.browserapierrors.setTimeout), with the stackMarker._evaluateOpacity → Map._queryFogOpacity → Fog.getOpacityAtLatLng → Fog state getter. It has affected ~448 users and is ongoing on web.What is the root cause of that problem?
The crash lives in Mapbox GL's marker fog-opacity fade logic, and it is reachable only because the distance map renders with fog enabled.
When the map has fog (our custom style
mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmqships a fog layer), everyMarkerschedules a deferred opacity re-evaluation viasetTimeout(this._evaluateOpacity, 60). This only happens whenmap.getFog()(or globe/terrain) is truthy:App/src/components/MapView/MapViewImpl.web.tsx
Lines 260 to 313 in 54e5655
We render several markers inside
<Map>(current-position dot, the distance label, and each waypoint), so these fade timers are scheduled whenever the markers update (which happens during theflyTo/fitBoundsanimations the component triggers).The 60ms timer is the problem. If the user leaves the distance-map step (the component unmounts, the map is torn down, the style/fog is reset) while a fade timer is still pending, the timer fires against a
Fogobject whosepropertiesfield has not been (re)computed. In Mapbox GL,Fog.propertiesis only assigned inrecalculate(); a freshly constructed or torn-downFoghasproperties === undefined. The deferred_evaluateOpacitythen calls_queryFogOpacity→fog.getOpacityAtLatLng→ theFogstategetter, which doesthis.properties.get('range')on the undefinedproperties→ the exactTypeErrorin the report.Because the throw happens inside an async
setTimeoutcallback, theErrorBoundaryaround the map cannot catch it, so it escapes towindow.onerrorand is reported to Sentry:App/src/components/MapView/MapView.web.tsx
Lines 34 to 67 in 54e5655
The deeper point: fog is a 3D atmospheric effect that is only ever visible when the map is pitched or shown as a globe at very low zoom. Our distance map is a flat, top-down, street-level view (default and single-marker zoom are both 15) — fog is never visible here. So fog provides zero value on this screen while being the sole reason the crashing code path is reachable.
What changes do you think we should make in order to solve the problem?
Disable fog on the distance map by passing
fog={null}to thereact-map-gl<Map>component inMapViewImpl.web.tsx.react-map-glalready supports a declarativefogprop: on everystyledata/sourcedataevent it diffs the prop and callsmap.setFog(...)once the style is loaded. Passingfog={null}makes it callmap.setFog(null), which deletesstyle.fog. With fog gone:map.getFog()is now falsy), so the teardown race window disappears._queryFogOpacityshort-circuits early whenstyle.fogis absent and never touchesfog.properties, so theTypeErrorcan no longer occur.This is a one-prop, declarative change that re-applies automatically on any style reload (because
react-map-glre-runs its style-component sync onstyledata), and it has no visual impact on the flat, street-level distance map since fog is never rendered at that zoom/pitch.Compare branch: https://github.com/Expensify/App/compare/main...wildan-m:App:wildan/91294-disable-map-fog?expand=1
What alternative solutions did you explore? (Optional)
map.setFog(null)imperatively in a<Map>onLoadhandler. This works, butonLoadfires only once, so it would not re-apply if the style reloads; the declarativefog={null}prop is re-applied byreact-map-glon everystyledataevent and is more robust.mapbox-glviapatch-package. This touches vendored code and is harder to maintain; removing the unused fog at our layer addresses the same root cause without modifying the dependency.mapbox-glto a newer minor. This carries a large, app-wide regression surface for an unverified fix, whereas disabling unused fog on this one screen is minimal and targeted.melvin-bot commentedon May 21, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @ZhenjaHorbach (
External)melvin-bot commentedon May 21, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022057428385818148717
[-][Sentry: APP-MR] TypeError properties.get in mapbox-gl on distance-map step (web)[/-][+][$250] [Sentry: APP-MR] TypeError properties.get in mapbox-gl on distance-map step (web)[/+]73 remaining items
ZhenjaHorbach commentedon Jun 23, 2026
BugZero Checklist:
We updated
mapbox-gllibrary here[Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.
Link to discussion: NA
[Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.
Regression Test Proposal
Test:
Do we agree 👍 or 👎
Zapier Logs
Run ID: 00040eee-1eef-a459-8f4c-0c84e0885b54melvin-bot commentedon Jun 23, 2026
Current assignee @mallenexpensify is eligible for the Awaiting Payment assigner, not assigning anyone new.
mallenexpensify commentedon Jun 27, 2026
Payment Summary
Contributor: @wildan-m paid $250 via Upwork
Contributor+: @ZhenjaHorbach due $250 via NewDot
^ Test case created. Thx
ZhenjaHorbach commentedon Jun 27, 2026
@mallenexpensify
I suppose we need to add @wildan-m in payment summary also 😅
mallenexpensify commentedon Jun 27, 2026
Thanks @ZhenjaHorbach , apologies for missing you @wildan-m, paid, summary updated above. Thx.
garrettmknight commentedon Jun 29, 2026
$250 approved for @ZhenjaHorbach