Handle QuotaExceededError in StyleStore (#1253)

## Launch Checklist

<!-- Thanks for the PR! Feel free to add or remove items from the
checklist. -->

When localStorage is full you start getting a "QuotaExceededError".
RIght now Maputnik does not handle this situation gracefully, it just
fails loading the style with a non-descriptive error message.

This PR purges localStorage and tries again when this particular error
happens.

It still does not show a descriptive error message. If you try to load a
style that is larger than what localStorage can handle, it will still
fail with a non-descriptive error message.

Increased the size of `example-style.json` so that it causes a
QuotaExceededError when running the regression test (try it before and
after this PR).


 - [x] Briefly describe the changes in this PR.
 - [x] Link to related issues. N/A
- [x] Include before/after visuals or gifs if this PR includes visual
changes. N/A
 - [x] Write tests for all new functionality.
 - [x] Add an entry to `CHANGELOG.md` under the `## main` section.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Bart Louwers
2025-07-04 10:27:00 +02:00
committed by GitHub
parent f5b7eccf52
commit 599240033a
5 changed files with 179 additions and 14 deletions

View File

@@ -11,6 +11,7 @@
- Upgrade to MapLibre LG JS v5
- Upgrade Vite 6 and Cypress 14 ([#970](https://github.com/maplibre/maputnik/pull/970))
- Upgrade OpenLayers from v6 to v10
- When loading a style into localStorage that causes a QuotaExceededError, purge localStorage and retry
- _...Add new stuff here..._
### 🐞 Bug fixes

View File

@@ -8,8 +8,10 @@ const baseUrl = "http://localhost:8888/";
const styleFromWindow = (win: Window) => {
const styleId = win.localStorage.getItem("maputnik:latest_style");
const styleItem = win.localStorage.getItem(`maputnik:style:${styleId}`);
const obj = JSON.parse(styleItem || "");
const styleItemKey = `maputnik:style:${styleId}`;
const styleItem = win.localStorage.getItem(styleItemKey);
if (!styleItem) throw new Error("Could not get styleItem from localStorage");
const obj = JSON.parse(styleItem);
return obj;
};

View File

@@ -275,4 +275,44 @@ describe("modals", () => {
describe("sources", () => {
it("toggle");
});
describe("Handle localStorage QuotaExceededError", () => {
it("handles quota exceeded error when opening style from URL", () => {
// Clear localStorage to start fresh
cy.clearLocalStorage();
// fill localStorage until we get a QuotaExceededError
cy.window().then(win => {
let chunkSize = 1000;
const chunk = new Array(chunkSize).join("x");
let index = 0;
// Keep adding until we hit the quota
while (true) {
try {
const key = `maputnik:fill-${index++}`;
win.localStorage.setItem(key, chunk);
} catch (e: any) {
// Verify it's a quota error
if (e.name === 'QuotaExceededError') {
if (chunkSize <= 1) return;
else {
chunkSize /= 2;
continue;
}
}
throw e; // Unexpected error
}
}
});
// Open the style via URL input
when.click("nav:open");
when.setValue("modal:open.url.input", get.exampleFileUrl());
when.click("modal:open.url.button");
then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
then(get.styleFromLocalStorage()).shouldExist();
});
});
});

View File

@@ -1,12 +1,114 @@
{
"id": "test-style",
"version": 8,
"name": "Test Style",
"metadata": {
"maputnik:renderer": "mlgljs"
},
"sources": {},
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
"layers": []
"id": "test-style",
"version": 8,
"name": "Test Style",
"metadata": {
"maputnik:renderer": "mlgljs",
"data": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43,
44,
45,
46,
47,
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59,
60,
61,
62,
63,
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75,
76,
77,
78,
79,
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91,
92,
93,
94,
95,
96,
97,
98,
99
]
},
"sources": {},
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
"layers": []
}

View File

@@ -91,8 +91,28 @@ export class StyleStore {
save(mapStyle: StyleSpecification & { id: string }) {
mapStyle = style.ensureStyleValidity(mapStyle)
const key = styleKey(mapStyle.id)
window.localStorage.setItem(key, JSON.stringify(mapStyle))
window.localStorage.setItem(storageKeys.latest, mapStyle.id)
const saveFn = () => {
window.localStorage.setItem(key, JSON.stringify(mapStyle))
window.localStorage.setItem(storageKeys.latest, mapStyle.id)
}
try {
saveFn()
} catch (e) {
// Handle quota exceeded error
if (e instanceof DOMException && (
e.code === 22 || // Firefox
e.code === 1014 || // Firefox
e.name === 'QuotaExceededError' ||
e.name === 'NS_ERROR_DOM_QUOTA_REACHED'
)) {
this.purge()
saveFn() // Retry after clearing
} else {
throw e
}
}
return mapStyle
}
}