Skip to content

fix(images): register image views mounted after the map is ready (Android) - #4283

Open
duysolo wants to merge 1 commit into
rnmapbox:mainfrom
duysolo:fix/register-late-mounted-image-views
Open

fix(images): register image views mounted after the map is ready (Android)#4283
duysolo wants to merge 1 commit into
rnmapbox:mainfrom
duysolo:fix/register-late-mounted-image-views

Conversation

@duysolo

@duysolo duysolo commented Aug 18, 2026

Copy link
Copy Markdown

Summary

RNMBXImages connects its children into the map only inside addToMap, and never reconciles
afterwards, so an image view that arrives at a different moment is silently left unwired. Two places
on Android:

# Where Effect
1 RNMBXImagesManager.addView A child mounted after the component was added to the map is appended to mImageViews but never given the map, because addToMap only visits the views present when it ran. It can then never place its own child view.
2 RNMBXImage.addToMap The reverse order, and the common one. With Fabric the child view is mounted into RNMBXImage before RNMBXImage is mounted into RNMBXImages, so addView() runs while mMapView is still null - and since addView() never calls super.addView, the child is not placed anywhere at all.

Why the symptom is confusing

A view in no hierarchy is never attached to a window, and a Drawee controller only submits its
request on attach. But drawing a view into a bitmap does not require attachment. So the
marker snapshots complete - circle, border, badge, text - and the only thing missing is the
<Image> inside it, with no onLoad, no onLoadStart and no onError to show for it.

Two probes that look like they should catch this, and do not:

  • onLayout fires with correct, non-zero bounds. Fabric applies layout metrics from the shadow
    tree whether or not the view is in a window.
  • measureInWindow returns plausible coordinates for the same reason - not the ~-10000 offset of
    offscreenAnnotationViewContainer, which is what a genuinely placed view reports.

Case 2 is not an edge case, it is the first mount. In the app I measured, 27 of 27 image views
mounted with the map reported no onLoad within a 4 s watchdog window; every marker rendered
without its image. After the change the same watchdog reports 27 of 27 loaded.

What applications do about it today

Because a later child never registers, apps remount the whole <Images> block whenever a new image
appears - which tears down and re-snapshots every image already on the map to add one.

Measured in a production app (Redmi, Android 16, RN 0.85.3, Fabric, @rnmapbox/maps 10.3.5), by
timestamping every onLoad against the remount that caused it:

before after
adding 2 images 90 loads, markers blank 1.5 - 5.7 s -
adding 1 image - 1 load
whole session - 120 new images -> 125 loads
watchdog per generation expected 27, loaded 0 expected 27, loaded 27, and 9 of 9 later generations silent: 0

Three map-type switches (standard / satellite / terrain) plus repeated fast panning, including
53-image and 38-image sets: all images intact afterwards.

The change

23 added lines, no deletions, 3 files:

  • RNMBXImages.attachImageViewIfOnMap (new): hand the map to a view that mounted later.
  • RNMBXImagesManager.addView: call it.
  • RNMBXImage.addToMap: place mChildView if it arrived before the map did.

Placement is guarded on childView.parent == null, so addToMap stays idempotent - it runs again
on style changes.

Reproducer

Based on example/src/examples/BugReportExample.js, self-contained, no extra libraries. EARLY
mounts with the map; LATE mounts when you press Start. Both draw the same view: a coloured square
with a remote <Image> on top. Counters show onLoadStart / onLoad / onError and any
onImageMissing.

Expected: two markers, each showing the picture. On 10.3.5 the picture never appears and the image
counters stay at zero.

Honest status, per .github/REPRODUCING.md item 5: I could not run this scene in the example
app
on my machine. The example app crashes at startup on java.lang.NoClassDefFoundError: Failed resolution of: Lcom/facebook/react/viewmanagers/RNMBXCameraManagerInterface; from
PackageList.getPackages, before any scene loads. It is not caused by this change: the unfixed
build fails identically, and the class is in fact present in the APK (classes15.dex), so it looks
like a local dex/autolinking problem rather than a code issue. The measurements above therefore come
from a production app on a real device, instrumented to log every onLoadStart / onLoad /
onError / onImageMissing and to watchdog images that never load - not from this scene. If a
maintainer can run it, it should fail on 10.3.5 and pass with this patch.

BugReportExample.js
import React, { useState, useCallback, useRef } from 'react';
import { View, Text, Button, Image as RNImage } from 'react-native';
import { MapView, Camera, Images, Image, ShapeSource, SymbolLayer } from '@rnmapbox/maps';

/**
 * Reproducer: an <Images> child mounted after the map is ready never registers,
 * and on Android an <Image> inside any <Images> child never loads at all.
 *
 * EARLY marker mounts with the map. LATE marker mounts when you press Start.
 * Both draw the same view: a coloured square with a remote <Image> on top of it.
 *
 * Expected: two markers, each showing the picture.
 * Actual on 10.3.5, Android: both markers appear as bare squares - the picture
 * never loads, and onLoad never fires for either of them.
 *
 * (iOS is expected to lose the LATE marker entirely, with onImageMissing firing
 * for `late-marker`, but that half is not verified and not part of this PR.)
 */

const PICTURE = 'https://docs.mapbox.com/mapbox-gl-js/assets/washington-monument.jpg';

const points = {
  type: 'FeatureCollection',
  features: [
    {
      type: 'Feature',
      id: 'early',
      properties: { icon: 'early-marker' },
      geometry: { type: 'Point', coordinates: [-74.0135, 40.7127] },
    },
    {
      type: 'Feature',
      id: 'late',
      properties: { icon: 'late-marker' },
      geometry: { type: 'Point', coordinates: [-73.9985, 40.7127] },
    },
  ],
};

const styles = {
  map: { flex: 1 },
  bar: { padding: 8 },
  line: { fontSize: 12, fontFamily: 'Courier' },
  marker: {
    width: 64,
    height: 64,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#1f6feb',
  },
  picture: { width: 48, height: 48 },
};

const BugReportExample = () => {
  const [lateMounted, setLateMounted] = useState(false);
  const [, forceRender] = useState(0);
  const counts = useRef({ loadStart: 0, load: 0, error: 0, missing: [] });

  const bump = useCallback((key, imageKey) => {
    if (key === 'missing') {
      counts.current.missing.push(imageKey);
    } else {
      counts.current[key] += 1;
    }
    forceRender((n) => n + 1);
  }, []);

  const marker = (label) => (
    <View collapsable={false} style={styles.marker}>
      <RNImage
        source={{ uri: PICTURE }}
        style={styles.picture}
        onLoadStart={() => bump('loadStart')}
        onLoad={() => bump('load')}
        onError={() => bump('error')}
      />
      <Text>{label}</Text>
    </View>
  );

  const c = counts.current;

  return (
    <>
      <View style={styles.bar}>
        <Button
          title={lateMounted ? 'Reset' : 'Start: mount the late marker'}
          onPress={() => setLateMounted((v) => !v)}
        />
        <Text style={styles.line}>late marker mounted: {String(lateMounted)}</Text>
        <Text style={styles.line}>
          image onLoadStart {c.loadStart} / onLoad {c.load} / onError {c.error}
        </Text>
        <Text style={styles.line}>onImageMissing: {c.missing.join(', ') || '(none)'}</Text>
      </View>
      <MapView style={styles.map}>
        <Camera centerCoordinate={[-74.006, 40.7127]} zoomLevel={12} />
        <Images onImageMissing={(imageKey) => bump('missing', imageKey)}>
          <Image name="early-marker">{marker('early')}</Image>
          {lateMounted ? <Image name="late-marker">{marker('late')}</Image> : null}
        </Images>
        <ShapeSource id="points" shape={points}>
          <SymbolLayer
            id="points-symbols"
            style={{ iconImage: ['get', 'icon'], iconAllowOverlap: true }}
          />
        </ShapeSource>
      </MapView>
    </>
  );
};

export default BugReportExample;

Scope and risks

  • No JS/TS API change, no spec change; yarn generate produces no diff (verified by running it).
  • attachImageViewIfOnMap is a new public method on RNMBXImages. Additive only.
  • Both mount orders are covered. Child-then-map by addToMap, map-then-child by the manager;
    whichever runs second finds the other side already present.
  • Style changes. addToMap runs again after a style reload; the parent == null guard makes
    the placement a no-op and the existing refresh() in the style-loaded callback re-registers the
    bitmaps. Verified by hand on a device.
  • Placement does not disturb layout. offscreenAnnotationViewContainer is a FrameLayout with
    LayoutParams(0, 0); markers render at the correct size after the change, so being placed there
    does not collapse the child.
  • Known adjacent issue, deliberately not fixed here. RNMBXImageManager.onDropViewInstance does
    not detach mChildView from offscreenAnnotationViewContainer, and RNMBXImage does not
    override removeView. That leak already exists for every child that was successfully placed; this
    change makes more children get placed, so it applies to more views. Happy to fix it here or in a
    follow-up - tell me which you prefer.
  • iOS has the same class of bug (addImageView appends to imageViews, but image.images is
    only assigned by addImageViews() from addToMap()), and a one-line fix works in my app. I am
    not including it here because I have not reproduced it on iOS. Happy to send it separately.

Verification

status
Android, production app, real device measured before/after, numbers above
Android, example app reproducer could not run - see Reproducer section
yarn lint 0 errors
yarn type:check clean
yarn unittest 112 / 112
yarn generate no diff
Android example build BUILD SUCCESSFUL, unfixed and fixed

…roid)

RNMBXImages only connects its children during addToMap and never reconciles
afterwards, so an image view that arrives at a different moment is silently
left unwired. Two places on Android:

- RNMBXImagesManager.addView: a child mounted after the component was added
  to the map is appended to mImageViews but never given the map, because
  addToMap only visits the views that were present when it ran. It can then
  never place its own child view.

- RNMBXImage.addToMap: the reverse order, and the common one. With Fabric
  the child view is mounted into RNMBXImage before RNMBXImage is mounted
  into RNMBXImages, so addView() runs while mMapView is still null - and
  since addView() does not call super, the child is not placed anywhere at
  all.

A view in no hierarchy is never attached to a window, and a Drawee
controller only submits its request on attach, so an <Image> inside that
child never loads. Drawing a view into a bitmap does not require
attachment, which is why such a marker snapshots complete except for its
image, with no onLoad, no onLoadStart and no onError to show for it.

Because a later child never registers, applications remount the whole
<Images> block whenever a new image appears, which tears down and
re-snapshots every image already on the map to add one. Measured in a
production app: 90 reloads to add 2 images, markers blank for up to 5.7
seconds; afterwards 1 image costs 1 load.

Placement is guarded on childView.parent == null, so addToMap stays
idempotent - it runs again on style changes.
@duysolo
duysolo requested a deployment to CI with Mapbox Tokens August 18, 2026 13:27 — with GitHub Actions Waiting
@duysolo
duysolo requested a deployment to CI with Mapbox Tokens August 18, 2026 13:27 — with GitHub Actions Waiting
@duysolo
duysolo requested a deployment to CI with Mapbox Tokens August 18, 2026 13:27 — with GitHub Actions Waiting
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant