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)
- 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.
- 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.
- 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)
Summary
Since ~v1.21,
<Marker tracksViewChanges={true}>whose React subviews animate viaAnimatedwithuseNativeDriver: truefreezes 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: intcounter was added toMapMarker.java, gating theViewChangesTrackersnapshot loop. The gate meanstracksViewChanges={true}now semantically requires continuedrequestLayout()calls to keep snapshotting, rather than snapshotting continuously while the flag is true.updatedis only incremented by code paths that go through layout (requestLayout(),update(true),update(int, int), etc.).Animated.timingwithuseNativeDriver: truedoes not triggerrequestLayout()— the native driver applies transforms directly at paint time, callinginvalidate()but notrequestLayout(). Soupdatedis bumped only by initial mount and a couple of cascading layout passes, runs out within ~2 tracker ticks, and the marker is dropped fromViewChangesTracker. From that point, theBitmapDescriptoron the native Google Maps marker is frozen.ViewChangesTracker.javais byte-identical between 1.20.1 and the current version; the regression is entirely inMapMarker.java.Affected versions
Minimal repro
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)
updatedfrominvalidate()-ish paths too. Currently only layout-pass code bumps the counter. If native-driverinvalidate()also bumps it, continuous animations keep the loop alive without requiring a user-facing opt-in.tracksViewChanges="always"(or similar). Explicit user opt-in bypasses theupdatedcounter. Preserves current behavior as default; users with animated markers set the new prop. Simple, backwards-compatible.updateCustomForTrackingto 1.20.1's form. Drops the perf optimization entirely. This is what my app currently patches viapatch-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
updateCustomForTrackingviapatch-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
Environment