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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions