Skip to content

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

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

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

Conversation

@ajtrasatti

@ajtrasatti ajtrasatti commented Oct 31, 2026 •

Copy link
Copy Markdown

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 verify the render-frame counter stays unchanged across three more location/heading updates. 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.

Rebuilding with the original transparent-puck implementation makes the hidden-rendering assertion fail (render counter 82 → 199 during the observation window); restoring the fix passes. 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

Companion Android native pulse lifecycle fix: mapbox/mapbox-maps-android#2739. The two PRs address the hidden-rendering paths at their respective ownership layers.

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}
        onDidFinishRenderingFrame={frame}
        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>
  );
}

This branch has not been deployed

No deployments
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

1 participant