Skip to content

fix(android): stop unnecessary hidden location puck rendering - #4432

Open
ajtrasatti wants to merge 4 commits into
rnmapbox:mainfrom
ajtrasatti:fix/android-hidden-location-puck
Open

ajtrasatti wants to merge 4 commits into
rnmapbox:mainfrom
ajtrasatti:fix/android-hidden-location-puck

Conversation

@ajtrasatti

@ajtrasatti ajtrasatti commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem and fix

With LocationPuck visible={false}, the Android wrapper installs transparent images but still tells its location-component manager that the puck is shown. Moving locations continue interpolating the invisible puck, and requested pulsing can keep rendering a stationary hidden puck with a fixed camera. The pulse ring can also remain visible even though the puck is hidden.

Pass the actual visibility to the existing manager. Enable the location component when the puck is visible or the camera is following; otherwise disable it. Keep native ownership of the puck configuration while mounted, and suppress pulsing while hidden without changing the caller's requested settings.

Android following consumes the location component's indicator updates, so hidden following retains the enabled component and transparent puck. Showing the puck reapplies its configured images/model/pulse. No JS API changes.

Regression coverage

Adds three tests using the existing JUnit 5/Mockito setup:

  • A hidden puck stays disabled until following starts, then disables again when following stops.
  • Hiding a puck during following keeps the location component enabled.
  • Repeated hide/show cycles keep location-manager subscriptions balanced.

The manager accepts an injected locationManager for testing, with its existing instance as the default. All 17 Android JVM tests pass. Reverting the visibility change causes two of the three new tests to fail; restoring it passes.

Independent CI improvement: the existing Android build also runs the JVM tests with ./gradlew assemble :rnmapbox_maps:testDebugUnitTest. This adds no new dependency, job or emulator runner. There are no screenshots, CPU thresholds or timing assertions in the JVM tests.

Native validation

Validated in the React Native example, Release ARM64, Android 14 emulator, Mapbox 11.33.0 and RN 0.84.1, using a blank style and synthetic location/heading updates:

  • The original hidden moving-puck and hidden stationary-pulse cases continuously render. Patched fixed-camera hidden cases settle to zero native render-finished callbacks, within the same allowance of at most 10 frames across three further updates used in fix(ios): stop rendering hidden location pucks #4431.
  • Hidden normal/compass/course following tracks position and heading as appropriate; stopping following returns the map to idle.
  • A device-location update still reaches UserLocation.onUpdate while the puck is hidden and rendering is idle.
  • Custom-image/pulse and a valid asset:// 3D model restore after hide/show.

Additional two-map lifecycle validation passes: 20 rapid hide/show cycles return both maps to idle; style reloads work with following off/on; switching which mounted map follows leaves the inactive map idle; remounting both maps preserves following; actual background/foreground resumes hidden following and returns fixed-camera hidden maps to idle.

Release build, yarn generate, lint + 194 Jest tests, TypeScript and git diff --check pass. Jest mocks native modules; the JVM tests check settings/transitions and the emulator probe validates actual rendering. Physical-device energy and long-soak measurements remain outside this validation.

Related work

Android wrapper follow-up to merged #4431 and the maintainer's Android follow-up comment.

mapbox/mapbox-maps-android#2739 handles pulsing during actual native-layer hidden/stopped states. Transparent images leave that layer marked visible, so this wrapper change additionally honors the React Native visible={false} contract. Camera movement while following still renders intentionally.

Reproducer in the existing example app

Use the example's normal access-token setup. Replace example/src/examples/BugReportExample.js with the following and open it directly (or temporarily import it from example/index.js). Grant location permission, relaunch once, then press Start probe. Each stage runs for eight seconds; inspect the last three location updates after settling. Only the fully-rendered callback is counted. The custom map provider and UserLocation device provider are independent; feed a device location to check onUpdate.

import React, {useEffect, useRef, useState} from 'react';
import {Button, PermissionsAndroid, Text, View} from 'react-native';
import Mapbox from '@rnmapbox/maps';
import config from '../../env.json';
Mapbox.setAccessToken(config.accessToken);
const styleJSON=JSON.stringify({version:8,sources:{},layers:[{id:'background',type:'background',paint:{'background-color':'#d9e5da'}}]});
const pulseOn={isEnabled:true,radius:25,color:'#ff5050'};
const pulseOff={isEnabled:false,radius:25,color:'#ff5050'};
const customImages={'probe-custom':require('../assets/pin.png')};
const origin=[-71.107,42.337];
const defaults={centerCoordinate:origin,zoomLevel:16};
const cases=[
 {name:'removed-moving',removed:true},
 {name:'hidden-static',still:true},
 {name:'hidden-moving'},
 {name:'hidden-static-pulse',still:true,pulse:true},
 {name:'hidden-moving-pulse',pulse:true},
 {name:'hidden-follow-normal',follow:'normal',pulse:true},
 {name:'hidden-follow-compass',follow:'compass',pulse:true},
 {name:'hidden-follow-course',follow:'course',pulse:true},
 {name:'visible-custom-pulse',visible:true,pulse:true,custom:true},
 {name:'hidden-custom-pulse',pulse:true,custom:true},
 {name:'restored-custom-pulse',visible:true,pulse:true,custom:true},
 {name:'hidden-follow-restored',follow:'compass',pulse:true,custom:true},
 {name:'hidden-stopped-follow',pulse:true,custom:true},
 {name:'removed-after-follow',removed:true}
];
export default function Probe(){
 const map=useRef(null),full=useRef(0),heading=useRef(0),ready=useRef(false),updates=useRef(0);
 const [running,setRunning]=useState(false),[tick,setTick]=useState(0),[report,setReport]=useState('Ready');
 const stage=Math.min(Math.floor(tick/8),cases.length-1),mode=cases[stage];
 const coordinate=mode.still?origin:[origin[0]+tick*0.00004,origin[1]];
 const targetHeading=mode.still?90:(90+tick*5)%360;
 const current=useRef(null);current.current={stage,tick,targetHeading,name:mode.name,coordinate,follow:mode.follow||false,visible:!!mode.visible};
 useEffect(()=>{PermissionsAndroid.requestMultiple([PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION]);},[]);
 useEffect(()=>{if(!running)return;const id=setInterval(()=>setTick(t=>t+1),1000);return()=>clearInterval(id);},[running]);
 useEffect(()=>{let alive=true;const id=setInterval(async()=>{
  if(!running||!ready.current||!map.current)return;
  const center=await map.current.getCenter();const data={...current.current,full:full.current,center,heading:heading.current,updates:updates.current};
  if(alive){setReport(JSON.stringify(data));console.log('ANDROID_PUCK_PROBE '+JSON.stringify(data));}
 },1000);return()=>{alive=false;clearInterval(id);};},[running]);
 return <View style={{flex:1,paddingTop:35}}>
  <Button title={running?'Restart probe':'Start probe'} onPress={()=>{setTick(0);full.current=0;setRunning(true);}}/>
  <Text style={{height:160}}>{report}</Text>
  <Mapbox.MapView ref={map} style={{flex:1}} styleJSON={styleJSON}
    onDidFinishLoadingMap={()=>{ready.current=true;}}
    onDidFinishRenderingFrameFully={()=>{full.current++;}}
    onCameraChanged={e=>{heading.current=e.properties.heading;}}>
   <Mapbox.Camera defaultSettings={defaults} followUserLocation={!!mode.follow} followUserMode={mode.follow||'normal'} followZoomLevel={16}/>
   <Mapbox.CustomLocationProvider coordinate={coordinate} heading={targetHeading}/>
   <Mapbox.UserLocation visible={false} onUpdate={()=>{updates.current++;}}/>
   <Mapbox.Images images={customImages}/>
   {!mode.removed&&<Mapbox.LocationPuck visible={!!mode.visible} puckBearingEnabled puckBearing="heading" pulsing={mode.pulse?pulseOn:pulseOff} topImage={mode.custom?'probe-custom':undefined}/>}
  </Mapbox.MapView>
 </View>;
}

@mfazekas mfazekas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this does what it says. I checked it on an Android 14 emulator (Release, arm64) with a probe close to yours: blank style, fixed camera, custom provider at 1 Hz. Frames counted over ~4 location updates per stage:

  • hidden, moving: main 91, PR 0
  • hidden, stationary + pulse: main 74, PR 0
  • hidden, moving + pulse: main 85, PR 0
  • hidden, after following stops: main 64, PR 0

Hidden normal/compass/course following still tracks the same way as on main. UserLocation onUpdate keeps firing while the hidden puck is idle. Default, custom image, pulsing and an asset:// 3D model all come back after hide/show. Another bug this fixes, worth adding to the description: on main a hidden puck with pulsing still draws the pulse ring. 140 rapid hide/show cycles with the device provider don't add location threads (the #4225 leak), so turning the component off on hide looks safe. The JVM tests pass, and 7 of the 10 fail with the visibility changes reverted.

A few things I'd like simplified before merging:

  1. Pulsing extraction. The behavior change is only "no pulse while hidden", but the diff moves the whole pulsing block into a new top-level function, and its LOG_TAG uses become string literals. Please keep the block where it was and add if (!visible) location2.pulsingEnabled = false after it. That gives the same final state. RNMBXNativeUserLocationTest exists only because of the extraction. I'd drop it, or keep a single hide/show case if you can test it without the move.

  2. The comment in _apply describes the manager's enabled = showUserLocation || followUserLocation rule, not this call. The call is what turns the component off when hidden. Please remove it. If a note is needed, a one-liner on State.enabled is the right place.

  3. addToMap now calls showNativeUserLocation twice: directly, then again through _apply(). One is enough.

  4. @JvmOverloads on showNativeUserLocation and on the constructor isn't needed. All callers are Kotlin in this module, and it isn't public API we keep stable. Please add the visible parameter and update the three call sites. Injecting the LocationManager for tests is fine, but name the parameter locationManager rather than mLocationManager.

  5. Tests. Please trim LocationComponentManagerTest to the cases that pin the new behavior: hidden stays disabled until following starts, hiding during follow keeps the component enabled, and hide/show keeps subscriptions balanced. The "native owned configuration" and "shutdown" cases cover existing behavior. The first still passes with the fix reverted. The mockStatic/mockConstruction setup matches ViewTagResolverTest, so that part is fine.

The CI change is welcome: the existing Android JVM tests don't run in CI today. Please mention it in the description, since it is independent of the fix.

I'm not asking for an Android Detox test here, because the repo has no Android Detox setup. If that gets added later, the LocationPuckLifecycle fixture from #4431 can be reused.

@ajtrasatti

Copy link
Copy Markdown
Contributor Author

Thanks for the review and suggestions. I’ve updated the PR accordingly:

  • Kept the pulsing block in place with its original LOG_TAG references and added the final if (!visible) location2.pulsingEnabled = false override. Removed the extracted helper and its test class.
  • Removed the comment and duplicate manager call in addToMap.
  • Removed @JvmOverloads, made visible explicit at the three remaining call sites, and named the injected dependency locationManager.
  • Trimmed LocationComponentManagerTest to the three requested cases: hidden/follow transitions, hiding during follow, and balanced hide/show subscriptions.

I also added the visible pulse-ring bug to the description. CI enablement is included and described separately: the existing Android build now runs the JVM suite, with no new job, dependency, or emulator setup.

Locally, all 17 JVM tests and 194 Jest tests pass. Reverting visibility handling fails two of the three focused tests; restoring it passes. I rebuilt Release and reran the native probe after moving the pulse override: hidden fixed-camera states settle to idle with pulse on/off, and following, location callbacks, and configuration restoration still work.

@ajtrasatti
ajtrasatti requested a review from mfazekas November 4, 2026 16:30
@ajtrasatti
ajtrasatti marked this pull request as ready for review November 4, 2026 16:31

This branch was previously deployed

1 inactive (outdated) deployment
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.

2 participants