Skip to content

[Bug]: #4438

Closed
Closed

Description

@stianjensen

Mapbox Version

11.23.1

React Native Version

0.86.2

Platform

iOS

@rnmapbox/maps version

10.3.5

Standalone component to reproduce

  import React from 'react';
  import {Camera, MapView, StyleImport} from '@rnmapbox/maps';

  class BugReportExample extends React.Component {
    render() {
      return (
        <MapView style={{flex: 1}} styleURL="mapbox://styles/mapbox/standard">
          <Camera centerCoordinate={[-74.00597, 40.71427]} zoomLevel={14} />
          <StyleImport id="basemap" existing config={{lightPreset: 'night'}} />
        </MapView>
      );
    }
  }

Observed behavior and steps to reproduce

  1. Mount the component above (the StyleImport is present from the very first render, nothing is set later from state).
  2. Watch the map appear.

The first rendered frames use the Standard style's default day preset. Once the style has finished loading, the StyleImport config is applied and the map animates from day to night using the Standard style's lightPreset transition. In an app that is in dark mode this is a visible light flash on every map mount.

A screenshot burst (xcrun simctl io <udid> screenshot every ~100 ms while the screen opens) shows: blank map view → day frames → fade → night. Verified on an iOS 26 simulator; the Android implementation has the same ordering (see below) and shows the same fade.

Expected behavior

When a StyleImport with existing is mounted together with the MapView, the first rendered frame should already reflect its config, the same way MapInitOptions(mapStyle: .standard(lightPreset: .night)) on iOS or MapboxStandardStyle { lightPreset = ... } in Android Compose render night from the first frame without a transition.

Notes / preliminary analysis

Cause. RNMBXStyleImport applies its config only after the style is fully loaded:

  • iOS: waitForStyleLoad() returns true, so addToMap runs after StyleLoaded.
  • Android: requiresStyleLoad is left at its default true, and updateConfig goes through getMapboxMap().getStyle()?.setStyleImportConfigProperties(...), which is null until the style is loaded.

By the time the config is set, the map has already rendered with the import's default config, and setStyleImportConfigProperties animates lightPreset changes.

What the Mapbox SDKs do. Mapbox applies import configuration before rendering starts. On iOS, MapStyleReconciler applies the MapStyle import configs inside the runtime-styling layers callback, which fires on StyleDataLoaded with type == .style ("style document is loaded and parsed … before rendering is started"). mapbox-maps-ios 11.17.0 added MapInitOptions.mapStyle and the changelog entry shows a post-load setStyleImportConfigProperties(for: "basemap", configs: ["lightPreset": "dusk"]) as the "before" and init-time config as the "after". rnmapbox has no way to reach that path from JS today.

The style-JSON workaround does not work here. The workaround used on the web (mapbox/mapbox-gl-js#13001) is to load a root style whose imports sets the config:

{"version": 8, "imports": [{"id": "basemap", "url": "mapbox://styles/mapbox/standard", "config": {"lightPreset": "night"}}], "sources": {}, "layers": []}

Passing that as styleJSON to MapView renders a black map with only the app's own layers. The device log shows maps core rejecting the import:

Failed to load import: mapbox://styles/mapbox/standard. It is already part of the currently used style.

The MapView starts on the SDK's default style, which is Standard itself, so the import is refused. This may also be what #4180 is seeing.

Fix we are running (via patch-package, I can turn this into a PR if you want). Make StyleImport apply its config as soon as the style root is parsed instead of after the full load:

  • Do not wait for style load (waitForStyleLoad() -> false on iOS, requiresStyleLoad = false on Android).
  • In addToMap, subscribe to mapboxMap.onStyleDataLoaded (iOS) / subscribeStyleDataLoaded (Android) and apply the config whenever event.type == .style and styleImports
    contains the configured id. Also apply immediately in case the style is already loaded.
  • Return false from removeFromMap for RemovalReason.StyleChange so the component survives a styleURL change and re-applies on the new style; cancel the subscription on a
    real removal.

With this, instrumentation on iOS shows setStyleImportConfigProperties succeeding ~60 ms before the first renderFrameFinished, with basemap already present in styleImports, and the first rendered frame is night. Config updates after load (toggling dark mode while the map is open) still animate as before. setStyleImportConfigProperties does not throw at that point even though the style is not yet reported as loaded.

One more wrinkle: the cached style still fades. When the Standard style comes from the cache (second time the map is opened in a session, and typically in production), the config is still applied before the first renderFrameFinished, but the first frames visibly run Standard's 300 ms light-preset transition from day to night (a beige, then grey blank map). The core has evidently already evaluated the import's default values by the time the StyleDataLoaded event is delivered, so the config change is treated as a transition. Mapbox's own MapStyleReconciler avoids this because it applies the configuration inside the runtime-styling layers callback of setStyleURI(_:callbacks:), which runs synchronously after parsing; that callback is not reachable from the public API. The patch therefore applies the initial config with styleTransition set to a zero duration (mapboxMap.styleTransition on iOS, setStyleTransition on Android) and restores the style's own transition on the next RenderFrameFinished, so later config changes still animate. Verified with a 16 ms screen capture on iOS: the first visible map frame is night in both the cold and the cached case.

Additional links and references

Additional links and references

Activity

  1. added a commit that references this issue on Nov 9, 2026
    a0852b9
  2. stianjensen commented on Nov 9, 2026

    @stianjensen
    Author

    Thanks for the quick fix!

    One thing worth checking before this ships, because it was the final detail I found during testing (it's in the last paragraph of #4438, "the cached style still fades"):

    Applying the config on StyleDataLoaded/.style fixes the cold load, but when the Standard style comes from the cache (second mount in a session, and in practice every mount in production) the first frames still run Standard's 300 ms lightPreset transition.

    The config is applied before the first renderFrameFinished, but the core has already evaluated the import's default values, so it treats the config as a change and animates it.
    I did a 16 ms capture, and it shows up as a flat grey map for ~150 ms that fades to night. Counting only "day-bright" frames misses it.

    What fixed it for us was applying the initial config with the style transition set to zero, and restoring the style's own transition after the first rendered frame so later config changes still animate:

    iOS (RNMBXStyleImport.swift, in the apply from the StyleDataLoaded observer):

    let transition = mapView.mapboxMap.styleTransition
    mapView.mapboxMap.styleTransition = TransitionOptions(duration: 0, delay: 0, enablePlacementTransitions: transition.enablePlacementTransitions)
    try mapView.mapboxMap.setStyleImportConfigProperties(for: id, configs: config)
    restoreTransitionCancelable = mapView.mapboxMap.onRenderFrameFinished.observeNext { [weak mapView] _ in
      mapView?.mapboxMap.styleTransition = transition
    }

    Android probably needs similar treatment.

    Verified on iOS (simulator capture and a production build) that the first visible frame is night in both the cold and the cached case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug 🪲Something isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions