From 1c2b42afda2f92aa2d1beb0bbe2282808e8a6647 Mon Sep 17 00:00:00 2001 From: Anthony Trasatti Date: Sat, 31 Oct 2026 10:08:34 -0400 Subject: [PATCH 1/5] fix(ios): stop rendering hidden location pucks --- ios/RNMBX/RNMBXNativeUserLocation.swift | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/ios/RNMBX/RNMBXNativeUserLocation.swift b/ios/RNMBX/RNMBXNativeUserLocation.swift index 655919d8a..63d710e97 100644 --- a/ios/RNMBX/RNMBXNativeUserLocation.swift +++ b/ios/RNMBX/RNMBXNativeUserLocation.swift @@ -173,16 +173,8 @@ public class RNMBXNativeUserLocation: UIView, RNMBXMapAndMapViewComponent { } if (!visible) { - let emptyImage = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in } - location.options.puckType = .puck2D( - Puck2DConfiguration( - topImage: emptyImage, - bearingImage: emptyImage, - shadowImage: emptyImage, - scale: Value.constant(1.0) - ) - ) - return + // Camera following consumes location data independently of puck rendering. + location.options.puckType = nil } else if let model = model, let configuration = makePuck3DConfiguration(model) { location.options.puckType = .puck3D(configuration) } else { From e5d8306e154c57e6cbeeb9abdf11086a5a47b267 Mon Sep 17 00:00:00 2001 From: Anthony Trasatti Date: Sat, 31 Oct 2026 11:27:20 -0400 Subject: [PATCH 2/5] test(ios): cover hidden location puck rendering and following --- .github/workflows/ios-actions.yml | 2 +- example/e2e/locationPuck.e2e.js | 122 ++++++++++++++ .../src/examples/E2E/LocationPuckLifecycle.js | 149 ++++++++++++++++++ example/src/examples/E2E/index.js | 1 + 4 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 example/e2e/locationPuck.e2e.js create mode 100644 example/src/examples/E2E/LocationPuckLifecycle.js diff --git a/.github/workflows/ios-actions.yml b/.github/workflows/ios-actions.yml index 1d3367bb5..50b2e7aec 100644 --- a/.github/workflows/ios-actions.yml +++ b/.github/workflows/ios-actions.yml @@ -156,7 +156,7 @@ jobs: if: inputs.E2E_MODE == 'test' run: | npx detox --version - yarn detox test --configuration ios.debug.ci --take-screenshots failing --record-logs failing --capture-view-hierarchy enabled e2e/firstTest.e2e.js + yarn detox test --configuration ios.debug.ci --take-screenshots failing --record-logs failing --capture-view-hierarchy enabled e2e/firstTest.e2e.js e2e/locationPuck.e2e.js # Informational only, the snapshots are compared by the percy job - name: Capture visual snapshots diff --git a/example/e2e/locationPuck.e2e.js b/example/e2e/locationPuck.e2e.js new file mode 100644 index 000000000..224bb1c09 --- /dev/null +++ b/example/e2e/locationPuck.e2e.js @@ -0,0 +1,122 @@ +/* global device, element, by, waitFor, describe, it, beforeAll, beforeEach, afterEach */ + +const fs = require('fs'); +const { PNG } = require('pngjs'); +const { expect } = require('@jest/globals'); + +async function stateWhen(predicate, timeout = 15000) { + const deadline = Date.now() + timeout; + let state; + do { + const attributes = await element(by.id('puck-state')).getAttributes(); + state = JSON.parse(attributes.label); + if (predicate(state)) return state; + await new Promise((resolve) => setTimeout(resolve, 250)); + } while (Date.now() < deadline); + throw new Error( + `Puck state did not satisfy assertion: ${JSON.stringify(state)}`, + ); +} + +async function expectHiddenToSettle() { + // Let map setup / visibility transitions finish before observing three more locations. + const initial = await stateWhen((state) => state.ready); + const before = await stateWhen( + (state) => state.locations >= initial.locations + 3, + ); + const after = await stateWhen( + (state) => state.locations >= before.locations + 3, + ); + expect(after.frames).toBe(before.frames); +} + +async function expectCustomImage() { + const shot = PNG.sync.read( + fs.readFileSync(await device.takeScreenshot('custom-puck')), + ); + let magenta = 0; + for (let i = 0; i < shot.data.length; i += 4) { + if (shot.data[i] > 220 && shot.data[i + 1] < 40 && shot.data[i + 2] > 220) + magenta += 1; + } + expect(magenta).toBeGreaterThan(10); +} + +describe('iOS location puck lifecycle', () => { + beforeAll(async () => { + await device.launchApp({ permissions: { location: 'never' } }); + await device.setURLBlacklist(['.*\\.mapbox\\.com/.*']); + }); + + beforeEach(async () => { + await waitFor(element(by.text('E2E'))) + .toBeVisible() + .whileElement(by.id('example-list')) + .scroll(200, 'down'); + await element(by.text('E2E')).tap(); + await waitFor(element(by.text('Location Puck Lifecycle'))) + .toBeVisible() + .whileElement(by.id('example-list')) + .scroll(100, 'down'); + await device.disableSynchronization(); + await element(by.text('Location Puck Lifecycle')).tap(); + await waitFor(element(by.id('puck-state'))) + .toExist() + .withTimeout(15000); + await stateWhen((state) => state.ready); + }); + + afterEach(async () => { + await device.reloadReactNative(); + await device.enableSynchronization(); + }); + + it('stops rendering a hidden puck while locations continue arriving', async () => { + await expectHiddenToSettle(); + }); + + it('follows position and compass heading with the puck hidden', async () => { + await element(by.id('puck-follow')).tap(); + const before = await stateWhen((state) => state.mode === 'follow'); + const after = await stateWhen( + (state) => state.locations >= before.locations + 4, + ); + expect(after.center[0]).toBeGreaterThan(before.center[0]); + expect(Math.abs(after.center[0] - after.coordinate[0])).toBeLessThan( + 0.0002, + ); + expect(Math.abs(after.center[1] - after.coordinate[1])).toBeLessThan( + 0.0002, + ); + + await element(by.id('puck-compass')).tap(); + const start = await stateWhen((state) => state.mode === 'compass'); + const compass = await stateWhen( + (state) => state.locations >= start.locations + 4, + ); + const error = Math.abs( + ((compass.heading - compass.targetHeading + 540) % 360) - 180, + ); + expect(error).toBeLessThan(30); + expect(compass.frames).toBeGreaterThan(start.frames); + }); + + it('restores a custom image and pulse across repeated hide/show transitions', async () => { + for (let cycle = 0; cycle < 2; cycle++) { + await element(by.id('puck-visible')).tap(); + const shown = await stateWhen((state) => state.mode === 'visible'); + const before = await stateWhen( + (state) => state.locations >= shown.locations + 3, + ); + // Coordinates and heading are fixed in visible mode: ongoing frames exercise the pulse. + const after = await stateWhen( + (state) => state.locations >= before.locations + 2, + ); + expect(after.frames).toBeGreaterThan(before.frames); + await expectCustomImage(); + await element(by.id('puck-hidden')).tap(); + await stateWhen((state) => state.mode === 'hidden'); + await expectHiddenToSettle(); + } + }); +}); diff --git a/example/src/examples/E2E/LocationPuckLifecycle.js b/example/src/examples/E2E/LocationPuckLifecycle.js new file mode 100644 index 000000000..a3e43408b --- /dev/null +++ b/example/src/examples/E2E/LocationPuckLifecycle.js @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button, Text, View } from 'react-native'; +import Mapbox from '@rnmapbox/maps'; + +const origin = [-71.107, 42.337]; +const camera = { centerCoordinate: origin, zoomLevel: 16 }; +const styleJSON = JSON.stringify({ + version: 8, + sources: {}, + layers: [ + { + id: 'background', + type: 'background', + paint: { 'background-color': '#d9e5da' }, + }, + ], +}); +// A magenta pixel scaled into a square makes image restoration observable in screenshots. +const images = { + 'lifecycle-puck': { + uri: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z/D/PwAG/gL+/gUKLAAAAABJRU5ErkJggg==', + }, +}; +const pulsing = { isEnabled: true, radius: 30, color: '#00ffff' }; + +export default function LocationPuckLifecycle() { + const map = useRef(null); + const frames = useRef(0); + const heading = useRef(0); + const ready = useRef(false); + const [mode, setMode] = useState('hidden'); + const [pulseEnabled, setPulseEnabled] = useState(false); + const [tick, setTick] = useState(0); + const [state, setState] = useState('{}'); + const visible = mode === 'visible'; + const coordinate = visible ? origin : [origin[0] + tick * 0.00004, origin[1]]; + const targetHeading = visible ? 0 : (tick * 10) % 360; + const current = useRef(null); + current.current = { mode, locations: tick, coordinate, targetHeading }; + + useEffect(() => { + const timer = setInterval(() => setTick((value) => value + 1), 1000); + return () => clearInterval(timer); + }, []); + + useEffect(() => { + let mounted = true; + const timer = setInterval(async () => { + if (!ready.current || !map.current) return; + const center = await map.current.getCenter(); + if (mounted) { + setState( + JSON.stringify({ + ...current.current, + ready: true, + frames: frames.current, + center, + heading: heading.current, + }), + ); + } + }, 500); + return () => { + mounted = false; + clearInterval(timer); + }; + }, []); + + return ( + +