Skip to content

Android: custom markers with useNativeDriver children freeze since v1.21 — root cause in MapMarker.updateCustomForTracking #5905

@Nik-9649

Description

@Nik-9649

Summary

Since ~v1.21, <Marker tracksViewChanges={true}> whose React subviews animate via Animated with useNativeDriver: true freezes on Android after ~2 snapshot ticks (~80 ms). iOS is unaffected. This is a regression from v1.20.1 and is the underlying cause of the symptoms reported in closed-as-stale #5644 (which lacked a root-cause diagnosis).

Root cause

Between 1.20.1 and 1.21+, an updated: int counter was added to MapMarker.java, gating the ViewChangesTracker snapshot loop. The gate means tracksViewChanges={true} now semantically requires continued requestLayout() calls to keep snapshotting, rather than snapshotting continuously while the flag is true.

// 1.20.1 — continuous while tracking
public boolean updateCustomForTracking() {
    if (!tracksViewChangesActive) return false;
    updateMarkerIcon();
    return true;
}

// 1.21+ (current) — gated by `updated` counter
public boolean updateCustomForTracking() {
    if (!tracksViewChangesActive || updated == 0) {
        tracksViewChangesActive = false;
        return false;
    }
    updateMarkerIcon();
    if (updated > 0) updated--;
    return true;
}

updated is only incremented by code paths that go through layout (requestLayout(), update(true), update(int, int), etc.). Animated.timing with useNativeDriver: true does not trigger requestLayout() — the native driver applies transforms directly at paint time, calling invalidate() but not requestLayout(). So updated is bumped only by initial mount and a couple of cascading layout passes, runs out within ~2 tracker ticks, and the marker is dropped from ViewChangesTracker. From that point, the BitmapDescriptor on the native Google Maps marker is frozen.

ViewChangesTracker.java is byte-identical between 1.20.1 and the current version; the regression is entirely in MapMarker.java.

Affected versions

Minimal repro

import { useEffect, useRef } from "react";
import { Animated, Easing } from "react-native";
import { Marker } from "react-native-maps";

function PulsingMarker({ coordinate }) {
  const anim = useRef(new Animated.Value(0)).current;
  useEffect(() => {
    Animated.loop(
      Animated.timing(anim, {
        toValue: 1,
        duration: 2000,
        easing: Easing.out(Easing.quad),
        useNativeDriver: true,   // key: native driver bypasses layout
      }),
    ).start();
  }, [anim]);

  return (
    <Marker coordinate={coordinate} tracksViewChanges={true}>
      <Animated.View
        style={{
          width: 24, height: 24, borderRadius: 12, backgroundColor: "#6E01EF",
          transform: [{
            scale: anim.interpolate({ inputRange: [0, 1], outputRange: [1, 3] }),
          }],
          opacity: anim.interpolate({ inputRange: [0, 1], outputRange: [0.6, 0] }),
        }}
      />
    </Marker>
  );
}

Expected: Circle pulses continuously (works on iOS).
Actual on Android: Renders, shows 2–3 frames of expansion, then freezes.

Proposed fixes (in order of increasing API surface)

  1. Re-arm updated from invalidate()-ish paths too. Currently only layout-pass code bumps the counter. If native-driver invalidate() also bumps it, continuous animations keep the loop alive without requiring a user-facing opt-in.
  2. New opt-in prop tracksViewChanges="always" (or similar). Explicit user opt-in bypasses the updated counter. Preserves current behavior as default; users with animated markers set the new prop. Simple, backwards-compatible.
  3. Revert updateCustomForTracking to 1.20.1's form. Drops the perf optimization entirely. This is what my app currently patches via patch-package (see workaround). Posted as the simplest repro; not what I'd advocate upstream.

Happy to submit a PR for option 2 if that direction is agreeable.

Workaround for others hitting this in the meantime

Override updateCustomForTracking via patch-package:

 public boolean updateCustomForTracking() {
-    if (!tracksViewChangesActive || updated == 0) {
-        tracksViewChangesActive = false;
-        return false;
-    }
+    if (!tracksViewChangesActive) return false;
     updateMarkerIcon();
-    if (updated > 0) {
-        updated--;
-    }
     return true;
 }

Polling markerRef.current?.redraw() at 25 fps (per #5644) also works but costs a JS-bridge round-trip per frame.

Related

  • Closed-as-stale #5644 — same symptoms, no root cause analysis.
  • ChromeQ's comment first documented v1.21+ as the regression boundary.

Environment

  • react-native-maps: 1.27.2
  • React Native: 0.83.4
  • Expo: SDK 55
  • New architecture: enabled (default in Expo SDK 55)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions