Repository: maplibre/maplibre-gl-js (2025-08-04T11:01:39Z)

Issue:
MapLibre mutates projection object when json passed to `setStyle`
**maplibre-gl-js version**: 5.6.0

**browser**: all

We rely on passing a js object to set styles in our style editor. We were having trouble changing projections by passing a JSON object. I initially thought there was a problem with our SDK (which extends ML Map) but upon digging deeper, I discovered the problem in ML

### Steps to Trigger Behavior

 1. navigate to demo
 2. observe styles in html source are passed as a js object,
 3. change styles
 4. observe that the original stylesheet is mutated

### Link to Demonstration

https://jsbin.com/gakuliy/1/edit?html,output

### Expected Behavior
Maplibre should not mutate the stylesheet that is passed.

### Actual Behavior
Maplibre internally mutates the stylesheet that is passed.

Some additional notes:
- I was also passing the styles through `structuredClone` before passing them to `setStyle`, this fixed the change from mercator to globe and back, but the stylesheet object is still mutated.
- when no projection is passed as part of the style, the projection does not default to mercator and remains the same (and subsequently does not fire a `projectiontransition` event), is this the expected behaviour or is it expected to always default back to mercator if no projection is passed in the style?


Discussion:
Can you clarify the problem statement? 
I'm not sure expecting an object you pass into the library not to be mutated is a correct expectation as cloning it might cause a performance issue...
Thanks for responding @HarelM .

So if you look at the example in the JSBin, we have two style objects that we switch between,`A` and `B`, both very similar with the exception of the projection.

When we switch from `A` to `B`, `A` (or more specifically `projection` object in `A`) is mutated so the json object no longer matches the config it's originally supposed to represent. So when we switch back to `A` _from_ `B` the projection in the style remains that of `B`.

In our specific use case these objects come from React state, but you can image that there would be other use cases where people would be passing json derived from some other state.

Now, I guess we could use `structuredClone(A)` before calling `setStyle` but then it would still need to be explicitly stated somewhere that this is needed and why it's needed, because from the outside it just manifests like an object reference error.

Does this help explain things a bit better?
Yes, thanks.
Is this the only part of the style that gets mutated? If so, it might be a bug in the projection code as it was added recently and might not go through the original code path of other properties.
Would you like to try and solve this?
I won't have any time out of work (kids), but I'll ask if we can maybe put it into our backlog and hopefully get to it in the coming month.
Also, yeah it only seems to be the projection object that gets mutated as far as I can tell.
It's probably there from the beginning of version 5 so another month is not too much :-)
I believe this is caused by the fact that `Map.setStyle` can either perform a diffed or full style update:
https://github.com/maplibre/maplibre-gl-js/blob/b1a2d8971000af94b7d0c5e6b8df28bf3c1acd53/src/ui/map.ts#L1858-L1864
and the user's style object is cloned in the former path but not the latter.

When diffed, `Map._diffStyle` → `Map._updateDiff` → `Style.setState` clones the user style on line 813 before setting `Style.stylesheet = nextState` on line 831.
This path is taken by default when the user calls `Map.setStyle`.
https://github.com/maplibre/maplibre-gl-js/blob/b1a2d8971000af94b7d0c5e6b8df28bf3c1acd53/src/style/style.ts#L805-L813

When not diffed, `Map._updateStyle` → `Style.loadURL`/`Style.loadJSON` → `Style._load` directly sets `Style.stylesheet = nextState`.
AFAIK this path is taken when the initial style is loaded.
https://github.com/maplibre/maplibre-gl-js/blob/b1a2d8971000af94b7d0c5e6b8df28bf3c1acd53/src/style/style.ts#L419-L426

Thus, I am able to observe the reported issue wherever `Style.stylesheet` is mutated via a line like `this.stylesheet.* = ...`, which includes `setProjection`, `setSky`, `setGlyphs`, and `setSprite` (though there may be more).

Here is a modified version of OP's demo which shows `sky`, `glyphs`, and `sprite` also being mutated: https://jsbin.com/zifexoyosu/edit?html,console,output
Note that by replacing `style: MERCATOR_MAP` with `style: INITIAL_STYLE` in the Map constructor, both MERCATOR_MAP and GLOBE_MAP remain unmutated even after switching back and forth between styles, since it is only the INITIAL_STYLE which takes the no-diff (and therefore no-clone) path.

I can submit a PR which adds a `clone` call to the no-diff path in `Style._load` if that seems like a reasonable solution.
The only drawback I can think of with this approach is that maybe this would hurt initial load times for complex styles, though I believe consistency in behaviour is generally more important than performance.
I believe all these methods were added after the fork and might not follow the original pattern of not mutating the style object.
It might be a good idea to revisit the the implementation there and see if cloning is needed as part of these code path.
Generally speaking, a lot of the times users place urls in a style and fetching the data from there is almost always immutable since the object is being fetched from a remote server etc...
Any improvements in this area would be highly appreciated.
It looks like `setProjection` and `setSky` mutate `Style.stylesheet` as a way to cache the raw style JSON value set by the user, if that makes sense.
Ideally, I was thinking we could get rid of `Style.stylesheet` entirely since it initially seemed to me like this just duplicated the data maintained by the internal instances like `Projection` and `Sky`. However, (presumably) since we want to differentiate between a user explicitly setting `projection: { type: 'mercator' }` and the default internal projection value of `{ type: 'mercator' }` (otherwise a `Style.getStyle` or `Style.getProjection` call would return `{ projection: { type: 'mercator' } }` even if the user never set the projection), we probably do have to store the user-set value *somewhere*.

I therefore feel the simplest solution would be to continue caching user-set values in `Style.stylesheet`, but shallow clone the style on load so that the problematic statements (e.g. `this.stylesheet.projection = projection`) no longer mutate the input style.

I will raise a PR today/tomorrow with this fix; we can move any further discussion regarding the approach/implementation there.
Thanks for looking into this! I'll take a look once it's opened.
I found this issue while investigating a bug in my own app.

When implementing the ability to switch from a vector map style that uses glyphs/sprites to a raster map style that does not (using setStyle), I encountered a situation where the glyph/sprite properties were being overwritten with undefined.
By observing the style object using Proxy/Reflect, I confirmed that the original object was being mutated by setGlyph and setSprite methods.

However, when I downgraded the version and tried it, the problem seemed to already exist, at least since maplibre-gl@3.3.0, and I thought it was a specification in a way, but is it not?
(The reason I selected v3.3.0 is that we do not have the style immediately at hand to support it prior to that time.)

I could not think of any other way than to `stracturedClone` the style, but the performance impact is significant and I would be very happy if it could be fixed.
@NEKOYASAN That indeed sounds like the same issue being discussed here.
AFAICT from the code, MapLibre never mutates the *contents* of the loaded stylesheet's `sprite` and `glyphs` fields, hence my proposal above to fix this via a shallow clone.
Assuming this is indeed the case, you could probably work around the issue by shallow cloning any style you pass to the constructor/`setStyle` which you wish not to be mutated; I believe the performance hit with a shallow clone should be fairly negligible.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 295b478ad2d..a18664ee181 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
 - _...Add new stuff here..._
 
 ### 🐞 Bug fixes
-- _...Add new stuff here..._
+- Prevent original input style JSON from being mutated by `Style.set*` methods ([#6216](https://github.com/maplibre/maplibre-gl-js/pull/6216))
 
 ## 5.6.2
 
diff --git a/src/style/style.ts b/src/style/style.ts
index 344ba46ff6b..7c3d62c9759 100644
--- a/src/style/style.ts
+++ b/src/style/style.ts
@@ -417,11 +417,13 @@ export class Style extends Evented {
     }
 
     _load(json: StyleSpecification, options: StyleSwapOptions & StyleSetterOptions, previousStyle?: StyleSpecification) {
-        const nextState = options.transformStyle ? options.transformStyle(previousStyle, json) : json;
+        let nextState = options.transformStyle ? options.transformStyle(previousStyle, json) : json;
         if (options.validate && emitValidationErrors(this, validateStyle(nextState))) {
             return;
         }
 
+        nextState = {...nextState};
+
         this._loaded = true;
         this.stylesheet = nextState;
 


Test patch:
diff --git a/src/style/style.test.ts b/src/style/style.test.ts
index 78397ea3182..6811f38c2ea 100644
--- a/src/style/style.test.ts
+++ b/src/style/style.test.ts
@@ -1132,6 +1132,54 @@ describe('Style.removeSource', () => {
     });
 });
 
+describe('Style.setProjection', () => {
+    test('does not mutate original input style JSON', async () => {
+        const style = new Style(getStubMap());
+        const inputJson = createStyleJSON({projection: {type: 'mercator'}});
+        const inputJsonString = JSON.stringify(inputJson);
+        const inputProjection = inputJson.projection;
+        style.loadJSON(inputJson);
+        await style.once('style.load');
+
+        style.setProjection({type: 'globe'});
+
+        expect(inputJson.projection).toBe(inputProjection);
+        expect(inputJsonString).toEqual(JSON.stringify(inputJson));
+    });
+});
+
+describe('Style.setSky', () => {
+    test('does not mutate original input style JSON', async () => {
+        const style = new Style(getStubMap());
+        const inputJson = createStyleJSON({sky: {'sky-color': 'fuchsia'}});
+        const inputJsonString = JSON.stringify(inputJson);
+        const inputSky = inputJson.sky;
+        style.loadJSON(inputJson);
+        await style.once('style.load');
+
+        style.setSky({'sky-color': 'magenta'});
+
+        expect(inputJson.sky).toBe(inputSky);
+        expect(inputJsonString).toEqual(JSON.stringify(inputJson));
+    });
+});
+
+describe('Style.setGlyphs', () => {
+    test('does not mutate original input style JSON', async () => {
+        const style = new Style(getStubMap());
+        const inputJson = createStyleJSON({glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'});
+        const inputJsonString = JSON.stringify(inputJson);
+        const inputGlyphs = inputJson.glyphs;
+        style.loadJSON(inputJson);
+        await style.once('style.load');
+
+        style.setGlyphs('https://foo.maplibre.org/font/{fontstack}/{range}.pbf');
+
+        expect(inputJson.glyphs).toBe(inputGlyphs);
+        expect(inputJsonString).toEqual(JSON.stringify(inputJson));
+    });
+});
+
 describe('Style.addSprite', () => {
     test('throw before loaded', () => {
         const style = new Style(getStubMap());
@@ -1178,6 +1226,20 @@ describe('Style.addSprite', () => {
             {id: 'test', url: 'https://example.com/sprite'}
         ]);
     });
+
+    test('does not mutate original input style JSON', async () => {
+        const style = new Style(getStubMap());
+        const inputJson = createStyleJSON({sprite: [{id: '0', url: 'https://example.com/sprite-0'}]});
+        const inputJsonString = JSON.stringify(inputJson);
+        const inputSprite = inputJson.sprite;
+        style.loadJSON(inputJson);
+        await style.once('style.load');
+
+        style.addSprite('1', 'https://example.com/sprite-1');
+
+        expect(inputJson.sprite).toBe(inputSprite);
+        expect(JSON.stringify(inputJson)).toEqual(inputJsonString);
+    });
 });
 
 describe('Style.removeSprite', () => {
@@ -1234,6 +1296,36 @@ describe('Style.removeSprite', () => {
         style.removeSprite('default');
         expect(style.stylesheet.sprite).toBeUndefined();
     });
+
+    test('does not mutate original input style JSON', async () => {
+        const style = new Style(getStubMap());
+        const inputJson = createStyleJSON({sprite: 'https://example.com/sprite'});
+        const inputJsonString = JSON.stringify(inputJson);
+        const inputSprite = inputJson.sprite;
+        style.loadJSON(inputJson);
+        await style.once('style.load');
+
+        style.removeSprite('default');
+
+        expect(inputJson.sprite).toBe(inputSprite);
+        expect(inputJsonString).toEqual(JSON.stringify(inputJson));
+    });
+});
+
+describe('Style.setSprite', () => {
+    test('does not mutate original input style JSON', async () => {
+        const style = new Style(getStubMap());
+        const inputJson = createStyleJSON({sprite: [{id: '0', url: 'https://example.com/sprite-0'}]});
+        const inputJsonString = JSON.stringify(inputJson);
+        const inputSprite = inputJson.sprite;
+        style.loadJSON(inputJson);
+        await style.once('style.load');
+
+        style.setSprite([{id: '1', url: 'https://example.com/sprite-1'}]);
+
+        expect(inputJson.sprite).toBe(inputSprite);
+        expect(inputJsonString).toEqual(JSON.stringify(inputJson));
+    });
 });
 
 describe('Style.setGeoJSONSourceData', () => {