Skip to content

fix(ios): stop rendering hidden location pucks - #4431

Merged
mfazekas merged 5 commits into
rnmapbox:mainfrom
ajtrasatti:fix/ios-hidden-location-puck
Nov 3, 2026
Merged

mfazekas merged 5 commits into
rnmapbox:mainfrom
ajtrasatti:fix/ios-hidden-location-puck

Conversation

@ajtrasatti

@ajtrasatti ajtrasatti commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Problem and fix

Fixes #3787: with LocationPuck visible={false}, a fixed camera and continuous GPS updates, the iOS wrapper still installs a 2D puck with transparent images. Mapbox interpolates that invisible puck's position and keeps the map rendering, even without pulsing enabled.

Set location.options.puckType to nil while hidden so Mapbox removes the puck renderer. Keep the component's configuration and location provider intact; showing it again rebuilds the configured puck.

Addressing the original report and maintainer concern

  • Unnecessary CPU while hidden: the original report identifies the transparent image and proposes puckType = nil. We reproduced that hidden-puck behavior on current main with continuous synthetic location updates and a fixed camera. The fixed build settles to zero native render callbacks, with CPU close to the removed-puck control below.
  • Camera following without a puck: the maintainer's concern was that following historically required a puck. We verified hidden normal following, visible compass following, then hidden compass following on Mapbox iOS 11.33.0. Position and heading continue following with no puck renderer. The native FollowPuckViewportState subscribes to location interpolation independently of the renderer; puck bearing settings do not control camera following.
  • Version compatibility: source checks of Mapbox iOS 11.0 and 11.8 also show camera following consuming location interpolation independently of the puck renderer. Those older versions were source-checked, not runtime-tested. Current rnmapbox supports Mapbox v11; v10 is no longer supported.

This addresses the original hidden-puck rendering report. The long-session freeze and onMapIdle event-frequency observations in later comments were not reproduced or validated by this change. Following a moving location intentionally continues rendering because the camera is moving.

Reproduction and validation

Built and ran the current React Native example with Mapbox iOS 11.33.0 on an iPhone 16 Pro simulator (iOS 18.2). The reproducer below uses a blank background, a fixed camera and synthetic location/heading updates once per second, with no external map data.

Settled state Unfixed Fixed
Hidden puck, camera fixed, moving location About 60 native render callbacks/s 0 native render callbacks/s
Same state, mean app CPU 19.99% 4.43%
Puck removed control, sampled separately, mean app CPU 4.42% 5.60%

CPU is the simulator app process, 100% = one host core, ten one-second CPU-time-delta samples after four warm-up samples, with no screen recording during sampling. These are controlled simulator measurements, not physical-device energy results. Frame callbacks indicate native render activity rather than displayed FPS.

Also verified visible pulse → hidden, and custom image/pulse → hidden → visible restoration. Hidden states with a fixed camera settle to zero render callbacks; showing the puck restores its configuration.

Native example build, yarn generate, yarn test (lint + 32 suites / 194 tests), yarn type:check and git diff --check pass. The Jest suite mocks native modules; the native Detox regression below and the before/after simulator reproduction validate the Swift behavior. Temporary diagnostic code and example configuration are excluded from the diff.

Automated iOS regression coverage

Adds one combined regression to the existing example/e2e/firstTest.e2e.js suite and a small local-style/synthetic-location scene:

  • With the puck hidden, let initial rendering settle and allow at most 10 additional fully rendered frames across three more location/heading updates, tolerating incidental redraws while rejecting continuous hidden-puck rendering. No pulsing is configured, matching the original report.
  • In the same mounted map, enable hidden compass following and verify the camera tracks both position and a known heading. This protects the behavior behind the maintainer's concern.

The full existing smoke suite passes (5 tests). The added case takes about 16 seconds locally, including navigation and cleanup. It reuses the existing app launch, has no screenshot processing, and adds no workflow changes, dependencies, or native test target. Assertions check behavior, without CPU thresholds or exact FPS.

A negative-control run with the original transparent-puck implementation and the single fully-rendered-frame callback recorded 121 additional frames during the observation window, well above the 10-frame allowance. Restoring the fix passes. The revised five-test suite was rechecked on an iPhone 16 Pro Max simulator running iOS 18.2 with Mapbox iOS 11.33.0. Custom-image/pulse restoration remains covered by the manual native validation above; its slower repeated screenshot checks are omitted from CI.

Run after building the example for Detox:

cd example
yarn detox test --configuration ios.debug.ci e2e/firstTest.e2e.js

Related Android work: mapbox/mapbox-maps-android#2739 addresses the native pulse lifecycle when the location component is hidden or stopped. I’ll also investigate the Android wrapper’s transparent-puck behavior for a follow-up PR in this repository, taking care to preserve camera following through the location component.

Reproducer: replace example/src/examples/BugReportExample.js

Use the example's existing access-token setup. Open this example directly (or temporarily import it in example/index.js). Start at hidden-moving, then use Next case to exercise removed/hidden/visible, camera-follow, pulse and custom-image transitions. Watch the live frame counter; rendering should stop in hidden states when the camera is fixed.

import React from "react";
import { Button, Text, View } from "react-native";
import Mapbox, {
  Camera,
  CustomLocationProvider,
  Images,
  LocationPuck,
  MapView,
} 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 defaults = { centerCoordinate: [-71.106, 42.337], zoomLevel: 16 };
const modes = [
  "hidden-moving",
  "removed-moving",
  "hidden-follow",
  "visible-follow",
  "hidden-follow-again",
  "visible-pulse",
  "hidden-after-pulse",
  "visible-custom",
  "hidden-custom",
  "restored-custom",
];
const customImages = { "probe-custom": require("../assets/pin.png") };
const pulse = { isEnabled: true, radius: 25, color: "#ff5050" };
export default function BugReportExample() {
  const [stage, setStage] = React.useState(0);
  const [tick, setTick] = React.useState(0);
  const [metrics, setMetrics] = React.useState("warming");
  const ref = React.useRef(null);
  const frames = React.useRef(0);
  const cameraHeading = React.useRef(0);
  const requestedHeading = React.useRef(0);
  const target = React.useRef(defaults.centerCoordinate);
  const mode = modes[stage];
  const follow = mode.includes("follow");
  const visible = mode.startsWith("visible") || mode === "restored-custom";
  const custom = mode.includes("custom");
  const coord = React.useMemo(
    () => [
      -71.107 + 0.001 * Math.cos(tick / 20),
      42.337 + 0.001 * Math.sin(tick / 20),
    ],
    [tick],
  );
  target.current = coord;
  requestedHeading.current = (tick * 3) % 360;
  React.useEffect(() => {
    const t = setInterval(() => setTick((v) => v + 1), 1000);
    return () => clearInterval(t);
  }, []);
  React.useEffect(() => {
    const timer = setInterval(async () => {
      const count = frames.current;
      frames.current = 0;
      try {
        const center = await ref.current.getCenter();
        const dx =
          (center[0] - target.current[0]) *
          111320 *
          Math.cos((42.337 * Math.PI) / 180);
        const dy = (center[1] - target.current[1]) * 111320;
        const text = `mode=${mode} frames=${count} cameraErrorM=${Math.hypot(dx, dy).toFixed(1)} center=${center.map((v) => v.toFixed(6)).join(",")} cameraHeading=${cameraHeading.current.toFixed(1)} requestedHeading=${requestedHeading.current}`;
        setMetrics(text);
        console.log("IOS_PUCK_PROBE", text);
      } catch (e) {
        setMetrics(String(e));
      }
    }, 1000);
    return () => clearInterval(timer);
  }, [mode]);
  const cameraEvent = React.useCallback((e) => {
    cameraHeading.current = e.properties.heading;
  }, []);
  const frame = React.useCallback(() => {
    frames.current += 1;
  }, []);
  return (
    <View style={{ flex: 1, paddingTop: 60, backgroundColor: "white" }}>
      <Text accessibilityLabel="probe-stage">{mode}</Text>
      <Button
        title="Next case"
        onPress={() => setStage((v) => (v + 1) % modes.length)}
      />
      <Button title="Reset hidden" onPress={() => setStage(0)} />
      <Text accessibilityLabel={metrics}>{metrics}</Text>
      <MapView
        ref={ref}
        style={{ flex: 1 }}
        styleJSON={styleJSON}
        onDidFinishRenderingFrameFully={frame}
        onCameraChanged={cameraEvent}
      >
        <Camera
          defaultSettings={defaults}
          followUserLocation={follow}
          followUserMode={mode === "hidden-follow" ? "normal" : "compass"}
          followZoomLevel={16}
        />
        <CustomLocationProvider coordinate={coord} heading={(tick * 3) % 360} />
        <Images images={customImages} />
        {mode !== "removed-moving" && (
          <LocationPuck
            visible={visible}
            puckBearingEnabled
            puckBearing="heading"
            pulsing={mode.includes("pulse") || custom ? pulse : undefined}
            topImage={custom ? "probe-custom" : undefined}
          />
        )}
      </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 for the careful write-up and the reproducer.

The Swift change looks right to me. In MapboxMaps 11.33, PuckManager drops both renderers and unsubscribes from onPuckRender when puckType is nil. FollowPuckViewportState subscribes to LocationManager.onPuckRender directly, and that signal is driven by the location data model, not by the puck renderer. So the old reason for the transparent puck (follow needed a puck) no longer applies on v11. Dropping the early return means puckBearingEnabled/puckBearing are now also applied while hidden. That's harmless.

I verified it on an iOS simulator with Release builds of main and this branch.

  1. With the puck hidden, a fixed camera and a moving location, main renders about 60 frames/s at ~11% CPU. This branch renders 0 frames/s at ~1.7% CPU, both with CustomLocationProvider and with the device provider on a simulated route. The same holds after hiding a pulsing or a 3D puck.
  2. UserLocation onUpdate keeps firing while hidden.
  3. Following still works with the puck hidden. In normal mode the camera tracks the route. In course mode the bearing follows the route's course. In compass mode the camera centre and heading match the provider's coordinate and heading.
  4. visible→hidden→visible brings back the default, bearing, pulsing, custom-image and 3D pucks as configured.

About the test:

  1. LocationPuckLifecycle is in the E2E group, which isn't in docs/examples.json. So it adds no doc screenshot or Percy image, and disableSync, waitForMapIdle and excludeFromPercy in its metadata have no effect (the Detox test passes disableSync explicitly). Please drop that metadata.
  2. The test's exact frames equality over a ~3 s window will be the flaky part on CI. Could it allow a small tolerance, or compare frames per second against a threshold well below the ~60/s the unfixed build produces?
  3. The example registers both onDidFinishRenderingFrame and onDidFinishRenderingFrameFully, so each frame may be counted twice. One of them is enough.

Android uses the same transparent-puck pattern in RNMBXNativeUserLocation.kt. That can be a follow-up, since Android follow goes through the location component and probably can't just drop the puck.

@ajtrasatti

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed feedback. I've addressed the three test comments:

  • Removed the unused metadata flags, keeping the title for navigation.
  • Kept only onDidFinishRenderingFrameFully for the blank-style fixture.
  • Replaced exact frame equality with an allowance of 10 additional fully rendered frames across three more location/heading updates, after startup settles. This gives incidental redraws some room while still rejecting continuous hidden-puck rendering.

All five native smoke tests pass with the updated threshold on an iPhone 16 Pro Max simulator running iOS 18.2 with Mapbox iOS 11.33.0, including hidden compass following. The earlier negative-control run with the original transparent-puck implementation and the same single-callback fixture recorded 121 additional frames in the observation window, well above the new allowance.

Lint and all 194 Jest tests also pass. This stays in the existing Detox suite with no additional CI job.

On Android, I’ve also opened [mapbox/mapbox-maps-android#2739](mapbox/mapbox-maps-android#2739) to address the native pulse lifecycle when the location component is hidden or stopped. I’ll also investigate the Android’s transparent-puck behavior and see whether a follow-up PR makes sense, while preserving the camera following.

@mfazekas
mfazekas merged commit 8642d97 into rnmapbox:main Nov 3, 2026
10 checks passed

This branch was previously deployed

1 inactive 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.

[Bug]: invisible LocationPuck causing high CPU load

2 participants