mapbox / mapbox/mapbox-maps-ios

[Bug]: Globe projection init race leaves camera in Mercator constraints (~50% of cold starts on RN 0.86 hosts) — map renders only atmosphere space-color; state is per-launch sticky and survives MapView recreation

Open
#2,436 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

auto-triaged bug :beetle:
Dominant language
Swift
Stars
601
Forks
196
PR merge metrics
No merged PRs in 30d

Description

## Environment

- Xcode: 16.x / macOS 15
- iOS: 18.x (physical iPhone 13) and iOS Simulator (iPhone 17 Pro) — identical behavior
- Maps SDK: **11.28.0** — reproduced identically on 11.23.1, 11.20.1 and 11.16.0 (same ~50% rate on all four)
- Host: React Native 0.86.2 (New Architecture, bridgeless) via `@rnmapbox/maps` 10.3.5 / Expo SDK 57
- Map: globe projection declared in a custom style JSON; custom atmosphere (`space-color #0D0806`); whole-earth zoom ~1.0; map mounted on the app's launch screen
- Regression context: identical app + SDK 11.23.1 worked on RN 0.81. RN 0.86 changed launch timing and exposed the race; **no SDK version in 11.16–11.28 changes the rate**, so this is a latent race, not a recent regression.

## Observed behavior and steps to reproduce

On **~50% of app cold starts** (measured: 13 automated batches of 10 cold starts each; range 3–8/10), the globe renders correctly for ~0.5–2 s and then the entire map renders **only the atmosphere `space-color`** — permanently. Pixel-verified: the "black" screen's mean RGB equals `#0D0806` exactly. Style-load events fire normally; **no error event of any kind**.

Steps: RN 0.86 New-Architecture app with a globe-projection MapView on the launch screen → force-quit → relaunch, repeatedly. ~Every second launch is affected.

### Instrumented engine state during the broken condition

We added logging inside `MapView.swift` (display-link callback, all pause/resume handlers, `reduceMemoryUse`, `scheduleRepaint`, `didMoveToWindow`, size tracking), sampling ~1 Hz:

- display link ticking normally, never paused; zero lifecycle events fire
- window attached, `windowScene.activationState == foregroundActive`
- MetalView: in window, not hidden, layer hosted, `layer.contents` set (never nil)
- bounds/drawableSize/`mapboxMap.size` all correct; `cameraState` sane; **padding zero**
- `needsDisplayRefresh` stays false — the core idles believing nothing changed; its last frame was empty space
- no `MapLoadingError`; `onDidFinishLoadingStyle` fired normally

### The fingerprint: Mercator camera constraints while the globe style is active

With the globe style confirmed loaded, camera flights to **lat 64.963 / lng −19.021** consistently settle at `cameraState.center = (27.026, −19.021)` — longitude exact, latitude wrong. **27.03° is exactly the Mercator center-latitude clamp for this viewport** (874-pt viewport vs 1035-px world at zoom 1.016). Globe projection has no such clamp. The camera subsystem is applying Mercator viewport constraints while the style/renderer is globe — and a globe renderer driven by Mercator-state transforms draws nothing but fog.

This occurs **even when the style is supplied at creation via `MapInitOptions(styleJSON:)`** — we eliminated the default-style→custom-style swap entirely and logged `born-with-style=YES` at creation on every run. Rate unchanged: the inconsistency arises during SDK initialization itself.

### The most diagnostic fact: the broken state is per-launch and outlives the MapView

- Destroying the MapView and creating a **fresh instance** at +6 s (same commit, or after a 500 ms gap with no map alive) — **the new map inherits the same broken verdict** in afflicted launches. Two maps in one launch always share one verdict (~50% launch-level rate, not 25% two-coin rate).
- Programmatic `removeFromSuperview` + re-add (same transaction *and* across committed frames): instrumentation confirms `didMoveToWindow(nil)` → `didMoveToWindow(window)` fired and the display link was recreated — **the core never re-rendered** (draw counter frozen).
- Camera commands never recover it.
- The **only** recovery (100% across two days of manual repros + 20/20 automated runs): a full host screen detach/re-attach (react-native-screens navigation cycle — the screen's native subtree torn down and remounted).

So the corrupted projection/transform state is not owned by the MapView instance; it behaves like per-launch state at or above the hosting layer that only a full host re-attach cycle resets.

### Also ruled out with 10-run batches each

Style content (reproduces with `mapbox://styles/mapbox/dark-v11` + globe), map children/annotations (reproduces with zero children), `takeSnap`, ambient cache (reproduces after `clearData()`), every app-side camera write (all removed — still reproduces), dev-launcher launch modes.

## Expected behavior

Globe projection either engages consistently at initialization, or the SDK surfaces an error/fallback signal when it cannot (prior art: the Android SDK's documented silent globe→Mercator hardware fallback at least logs a warning). A camera+renderer projection disagreement should not be silently persistent and unrecoverable.

## Notes

- Possibly related prior art: the v11 migration guide's known issue that `camera(for:padding:bearing:pitch:)` and `OverviewViewportState` can return wrong camera options with globe-projection styles — same family (camera subsystem disagreeing with globe projection), though our case is the live camera/render path, not a calculation helper.
- Detection workaround we can confirm: probing a flight to a high-latitude target and checking `cameraState.center.latitude` for the Mercator clamp reliably identifies the broken state at runtime.
- We can provide: full per-second instrumented logs during the broken state, the automated cold-start harness (measures failure rate in 10-run batches), and a screen recording — and we're happy to run any diagnostic build against the harness.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reproducing the cold-start failure with the described React Native 0.86 setup and automated harness, then inspect the globe projection and camera initialization paths around MapView.swift. Use the reported Mercator latitude clamp and per-launch persistence to compare camera and renderer state. Done means globe projection and camera constraints agree consistently, or initialization exposes an error or fallback signal.

Written by the indexing model from the issue text.

Assessment

Tech stack
ios, react-native, swift
Domain
computer-graphics, mobile-dev
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.