Repository navigation
Description
Mapbox Version
11.23.1 (the version 10.3.8 pins; 10.3.17 pins 11.33.0, not checked for a behaviour change in the core)
React Native Version
0.88.1 (New Architecture)
Platform
iOS
@rnmapbox/maps version
10.3.8 (observed from 10.3.5). The code involved is unchanged in 10.3.17, the newest release: sameImage and addImages in RNMBXImages.swift are the same, and RNMBXMapView.swift still has no styleImageRemoveUnused subscriber. 10.3.17's source was read but not run.
Standalone component to reproduce
import React, {useState} from 'react';
import {Button, View} from 'react-native';
import {MapView, Camera, Images, ShapeSource, SymbolLayer} from '@rnmapbox/maps';
const N = 30;
const points = {
type: 'FeatureCollection',
features: Array.from({length: N}, (_, i) => ({
type: 'Feature',
id: `p${i}`,
properties: {id: `p${i}`},
geometry: {
type: 'Point',
coordinates: [-74.0 + (i % 6) * 0.01, 40.7 + Math.floor(i / 6) * 0.01],
},
})),
};
const imagesFor = count =>
Object.fromEntries(
Array.from({length: count}, (_, i) => [
`p${i}`,
{uri: `https://picsum.photos/seed/p${i}/96`},
]),
);
export default function BugReportExample() {
const [count, setCount] = useState(N);
return (
<View style={{flex: 1}}>
<MapView style={{flex: 1}}>
<Camera centerCoordinate={[-73.975, 40.72]} zoomLevel={13} />
<Images
images={imagesFor(count)}
onImageMissing={id => console.log('imageMissing', id)}
/>
<ShapeSource id="points" shape={points}>
<SymbolLayer
id="photos"
style={{iconImage: ['get', 'id'], iconAllowOverlap: true}}
/>
</ShapeSource>
</MapView>
<Button title="Add one image" onPress={() => setCount(c => c + 1)} />
</View>
);
}This is reduced from the app where both defects reproduce; it has not been run as written. Remote images are the subject of the bug, so it loads them from a public image host.
Observed behavior and steps to reproduce
Two defects, both in the iOS <Images> path for remote { uri } images.
- Let the photos load, pan until those points leave the rendered area, then pan back.
onImageMissingfires again for each returning key, and the symbols are drawn without their photos until the images download again. With 48 to 120 KB images served with some latency, the gap was 4 to 5 seconds. - Tap "Add one image". Every existing key is downloaded and added to the style again, not only the new one (watch the image host's requests through a proxy).
Expected behavior
- An image that
<Images>still lists stays in the style while its features are off screen, so returning features draw at once, as on Android. - Changing
imagesdownloads only the keys that are new or whoseurichanged, as on Android (RNMBXImages.ktsetImagescomparesvalue.uri != oldValue.uri).
Notes / preliminary analysis
1. Nothing subscribes to StyleImageRemoveUnused, so the map deletes requested images once no tile uses them. MBMStyleImageRemoveUnused.h (MapboxCoreMaps 11.23.1):
An image requested by the
mapthrough theStyleImageMissingevent is no longer needed and can be removed using theremoveStyleImagemethod. The application can decide whether the image should be kept or removed.Note: If there are no subscribers for the
StyleImageRemoveUnusedevent, themapwill automatically remove the unused image.
RNMBXMapView.swift declares styleImageRemoveUnused next to styleImageMissing but never subscribes to it. A remote image whose symbol was laid out before its download landed is a requested image (onEvery(event: .styleImageMissing) calls addMissingImageToStyle), so the core removes it when its feature leaves the rendered tiles. Android doesn't hit this because addRemoteImages adds a placeholder before layout. iOS's addImages has the same intent ("Add image placeholder for images that are not yet available in the style"), but the placeholder branch is only reached when sameImage(oldValue:newValue:) is true, which it never is for a new key.
2. sameImage is never true for { uri } entries.
func sameImage(oldValue: Any?, newValue: Any?) -> Bool {
if let oldS = oldValue as? String, let newS = newValue as? String {
return oldS == newS
} else {
return false
}
}A { uri } entry arrives as a dictionary, so every key present in both the old and new images goes into missingImages, and RNMBXUtils.fetchImages(..., forceUpdate: true) downloads and addImages it again.
The fix running in the app, as a patch on 10.3.8:
- Subscribe, and keep what an
<Images>still lists, inRNMBXMapView.swiftnext to thestyleImageMissinghandler:
self.onEvery(event: .styleImageRemoveUnused) { (self, event) in
let imageId = event.imageId
if self.images.contains(where: { $0.lists(imageName: imageId) }) {
return
}
self.withMapboxMap { map in
logged("RNMBXMapView", "removeUnusedImage") {
try map.removeImage(withId: imageId)
}
}
}with, in RNMBXImages:
func lists(imageName: String) -> Bool {
return images[imageName] != nil || nativeImageInfos.contains { $0.name == imageName }
}- Compare dictionary entries by value in
sameImage:
} else if let oldD = oldValue as? NSDictionary, let newD = newValue as? NSDictionary {
return oldD.isEqual(newD)
}Two consequences an upstream version would need to handle, both covered in the patch:
- Once the subscriber keeps listed images, nothing removes an image the app stops listing (before, the core removed requested ones). The patch removes images that leave
images(unless another<Images>on the map lists them), and drops a download that lands after its key was de-listed. - Comparing by value removes the accidental retry defect 2 gave: a failed download used to be fetched again on the next
imageschange, and the map reports a missing image only once, so nothing else retries. The patch retries a failed download after 2, 4 and 8 seconds while the image is still listed.
Additional links and references
MBMStyleImageRemoveUnused.hin MapboxCoreMaps 11.23.1 (quoted above).
Activity
mfazekas commentedon Nov 4, 2026
Thanks for the detailed write-up. I reproduced both defects on current
main(10.3.17, Mapbox 11.33.0, iPhone 17 simulator, Release build). Android behaves as you describe.Setup: your component, with
picsum.photosswapped for a local server that serves 256 px PNGs with 3 s latency and counts requests per key.onImageMissingfired again for all 30 keys and all 30 were downloaded again. Symbols came back without photos (3 of 20 visible after 2.5 s) and took about 24 s to fill in, sinceRNMBXImageQueueloads 4 at a time. On Android the same tour gave 0onImageMissingand 0 downloads.One more thing your analysis points to: on first load iOS already makes 60 requests for 30 keys (Android makes 30). No placeholder is added for new keys, so the layer reports each one missing, and
addMissingImageToStylestarts a second fetch while the first is still in flight. TheaddPlaceholderlocal inaddImagesis computed but never used, so it looks like a placeholder for new keys was the intent.Your two root causes match the code on
main. #4424 (unmount cleanup) didn't touch either path. TheMBMStyleImageRemoveUnused.hnote is the same in MapboxCoreMaps 11.33.0.On the patch:
sameImagecomparing dictionaries by value is the core of defect 2 and looks right.addRemoteImagesdoes, would remove the double download. It also keeps upfront-listed images out of the core's "requested" set, so they wouldn't be auto-removed. TheStyleImageRemoveUnusedsubscriber would still matter for images supplied lazily fromonImageMissing. If you keep it,lists(imageName:)should also cover<Image>children (imageViews), whichaddMissingImageToStylehandles since fix(images): register image views mounted after the map is ready (iOS) #4423/fix(ios): remove <Images> images from the style on unmount and drop stale map entries #4424. Removing de-listed keys could reuseremoveImages(style:names:)from fix(ios): remove <Images> images from the style on unmount and drop stale map entries #4424.Would you like to open a PR with the fix? Happy to review it and to run it against the reproducer above.
JaydeepAgravat commentedon Nov 8, 2026
Hi @jaredsteyn, are you planning to open the PR for this? If you'd rather not, I'm happy to take it, following @mfazekas's notes: compare images by value in
sameImage, add placeholders for new or changed keys before fetching (as Android'saddRemoteImagesdoes), and leave the retry/backoff out. I'd test it with the reproducer from the issue on the iOS simulator. Either way is fine with me, I don't want to step on your patch.